Files
Termind/TFMTests/CommandNotificationTests.swift

275 lines
13 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Foundation
import GhosttyTerminal
import TermindApplication
import TermindDomain
import TermindPresentation
import Testing
@testable import Termind
@Suite @MainActor
struct CommandNotificationTests {
@Test(arguments: ["hidden", "navigation", "snippets", "history", "peer", "archive", "visible"])
func assistantNotificationOpensSidebarWithoutReplacingWorkspace(_ state: String) throws {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
app.registerWindow(window)
let conversation = ConversationModel()
conversation.composer.draft = "Target"
conversation.sendDraft()
window.assistantLibrary.insertAndSelect(conversation)
let workspace = Workspace(pane: Pane(.empty))
workspace.splitFocusedPane(axis: .horizontal, with: Pane(.empty))
window.open(workspace)
let focusedPane = workspace.focusedPane
window.showAssistant()
switch state {
case "hidden": window.showsSidebar = false
case "navigation": window.sidebarMode = .navigation
case "snippets": window.sidebarMode = .snippets
case "history": window.sidebarMode = .history
case "peer":
let peer = ConversationModel()
peer.composer.draft = "Other conversation"
peer.sendDraft()
window.assistantLibrary.insertAndSelect(peer)
case "archive": window.showAssistantArchiveInSidebar()
default: break
}
#expect(window.isShowingAssistant(conversation.id) == (state == "visible"))
#expect(app.isAssistantNotificationFocused(conversation.id) == (state == "visible"))
window.isQuickOpenPresented = true
#expect(app.selectAssistantNotificationTarget(conversation.id) === window)
#expect(window.selectedWorkspace === workspace)
#expect(workspace.focusedPane === focusedPane)
#expect(window.showsSidebar && window.sidebarMode == .assistant)
#expect(window.isShowingAssistant(conversation.id))
#expect(!window.isAssistantArchivePresented)
#expect(!window.isQuickOpenPresented && !window.isToolbarFindFocused)
}
@Test(arguments: [true, false])
func assistantNotificationPrefersFocusThenStableOrderWithinMatchingWindows(_ visible: Bool) throws {
let app = AppModel(hosts: HostLibraryModel())
let windows = [WindowModel(), WindowModel()].sorted { $0.id.uuidString < $1.id.uuidString }
for window in windows { app.registerWindow(window) }
let conversation = ConversationModel()
conversation.composer.draft = "Target"
conversation.sendDraft()
windows[0].assistantLibrary.insertAndSelect(conversation)
for window in windows {
window.openAssistantFullScreen(conversation.id)
if !visible { window.selectVault() }
}
#expect(
app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { $0 === windows[1] })
=== windows[1])
if !visible { windows[1].selectVault() }
#expect(app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { _ in false }) === windows[0])
windows[0].closeForWindow()
#expect(app.selectAssistantNotificationTarget(conversation.id) === windows[1])
}
@Test func assistantNotificationReusesHiddenSelectionBeforeAnUnrelatedFocusedWindow() throws {
let app = AppModel(hosts: HostLibraryModel())
let owner = WindowModel()
let other = WindowModel()
app.registerWindow(owner)
app.registerWindow(other)
let conversation = ConversationModel()
conversation.composer.draft = "Target"
conversation.sendDraft()
owner.assistantLibrary.insertAndSelect(conversation)
owner.openAssistantFullScreen(conversation.id)
let workspace = Workspace(pane: Pane(.empty))
owner.open(workspace)
owner.showsSidebar = false
#expect(!app.isAssistantNotificationFocused(conversation.id))
#expect(app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { $0 === other }) === owner)
#expect(owner.selectedWorkspace === workspace)
#expect(owner.isShowingAssistant(conversation.id))
#expect(other.assistantLibrary.selectedConversationID == nil)
other.openAssistantFullScreen(conversation.id)
owner.showsSidebar = false
#expect(app.selectAssistantNotificationTarget(conversation.id, windowIsFocused: { $0 === owner }) === other)
#expect(!owner.showsSidebar)
}
@Test func notificationRouterUsesTheLatestClickAndConsumesItOnce() async throws {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
app.registerWindow(window)
let first = ConversationModel()
let second = ConversationModel()
first.composer.draft = "First"
second.composer.draft = "Second"
first.sendDraft()
window.assistantLibrary.insertAndSelect(first)
second.sendDraft()
window.assistantLibrary.insertAndSelect(second)
let activations = AsyncStream<Void>.makeStream()
var count = 0
let router = DesktopNotificationRouter(app: app) { _ in
count += 1
activations.continuation.yield(())
}
router.open(.assistant(first.id))
router.open(.assistant(second.id))
var iterator = activations.stream.makeAsyncIterator()
_ = await iterator.next()
#expect(window.assistantLibrary.selectedConversationID == second.id)
router.resume()
#expect(count == 1)
router.stop()
router.open(.assistant(first.id))
router.resume()
#expect(window.assistantLibrary.selectedConversationID == second.id)
}
@Test func manuallyUnreadWorkspacesCountOnceWithoutRequiringATerminal() {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
app.registerWindow(window)
let workspace = Workspace(pane: Pane(.empty))
window.open(workspace, select: false)
workspace.markCommandsUnread()
workspace.markCommandsUnread()
#expect(workspace.hasUnreadCommandCompletion)
#expect(window.unreadCompletionCount == 1)
#expect(app.unreadNotificationCount == 1)
workspace.markCommandsRead()
#expect(app.unreadNotificationCount == 0)
#expect(!Workspace(pane: Pane(.empty)).hasUnreadCommandCompletion)
}
@Test func appOwnsCommandNotificationsWithoutChangingThemeOrFontPrecedence() {
let state = TerminalViewState(configSource: .none)
let preferences = TerminalPreferences(
darkCustomEnabled: true,
darkCustomConfiguration: "notify-on-command-finish = always\nbackground = 010203")
state.applyPreferences(preferences)
#expect(state.controller.terminalConfiguration.rendered.contains("notify-on-command-finish = never"))
#expect(!state.theme.dark.rendered.contains("notify-on-command-finish = always"))
#expect(state.theme.dark.rendered.hasSuffix("background = 010203"))
}
@Test func workspaceFocusIncludesEveryPaneAndTargetsFollowMoves() throws {
let host = Host(name: "Build", host: "test.invalid")
let app = AppModel(hosts: HostLibraryModel(hosts: [host]))
let window = WindowModel()
let other = WindowModel()
app.registerWindow(window)
app.registerWindow(other)
let first = Pane.sshSession(host: host)
let second = Pane.sshSession(host: host)
let workspace = Workspace(pane: first)
workspace.splitFocusedPane(axis: .horizontal, with: second)
window.open(workspace)
let firstID = try #require(first.commandSessionID)
let secondID = try #require(second.commandSessionID)
#expect(workspace.focusedPane === second)
#expect(app.isCommandWorkspaceFocused(firstID, windowIsFocused: { $0 === window }))
#expect(app.isCommandWorkspaceFocused(secondID, windowIsFocused: { $0 === window }))
#expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in false }))
window.selectVault()
#expect(app.selectCommandNotificationTarget(firstID) === window)
#expect(window.selectedWorkspace === workspace)
#expect(workspace.focusedPane === first)
window.selectVault()
#expect(!app.isCommandWorkspaceFocused(firstID, windowIsFocused: { _ in true }))
let monitor = try #require(first.commandMonitor)
monitor.beginConnection()
monitor.receiveOSC133(exitCode: 0, durationNanos: 0)
#expect(workspace.hasUnreadCommandCompletion)
#expect(window.unreadCompletionCount == 1)
#expect(other.unreadCompletionCount == 0)
let moved = try #require(window.takePaneForMove(first.id))
other.open(moved)
#expect(!workspace.hasUnreadCommandCompletion)
#expect(moved.hasUnreadCommandCompletion)
#expect(window.unreadCompletionCount == 0)
#expect(other.unreadCompletionCount == 1)
#expect(app.commandNotificationTarget(firstID)?.0 === other)
other.selectVault()
#expect(app.selectCommandNotificationTarget(firstID) === other)
#expect(other.selectedWorkspace === moved)
#expect(moved.focusedPane === first)
moved.markCommandsRead()
#expect(!moved.hasUnreadCommandCompletion)
#expect(other.unreadCompletionCount == 0)
other.close(moved.id)
#expect(app.commandNotificationTarget(firstID) == nil)
#expect(app.selectCommandNotificationTarget(firstID) == nil)
app.reopenClosedRecord(try #require(app.closedWorkspaceRecords.first?.id), in: other)
#expect(app.commandNotificationTarget(firstID) == nil)
#expect(first.commandSessionID != firstID)
}
@Test func assistantFocusIncludesVisibleSidebarAndNotificationTargetsReuseTheConversation() throws {
let app = AppModel(hosts: HostLibraryModel())
let window = WindowModel()
let other = WindowModel()
app.registerWindow(window)
app.registerWindow(other)
let conversation = ConversationModel()
conversation.composer.draft = "Test"
conversation.sendDraft()
window.assistantLibrary.insertAndSelect(conversation)
window.openAssistantFullScreen(conversation.id)
#expect(app.isAssistantNotificationFocused(conversation.id))
#expect(app.isAssistantNotificationFocused(UUID()))
window.open(Workspace(pane: Pane(.empty)))
window.sidebarMode = .assistant
window.showsSidebar = true
#expect(window.isShowingAssistant(conversation.id))
window.showsSidebar = false
#expect(!window.isShowingAssistant(conversation.id))
#expect(!app.isAssistantNotificationFocused(conversation.id))
other.openAssistantFullScreen(conversation.id)
#expect(app.selectAssistantNotificationTarget(conversation.id) === other)
#expect(other.assistantLibrary.selectedConversation === conversation)
let peer = ConversationModel()
peer.composer.draft = "Second conversation"
peer.sendDraft()
window.assistantLibrary.insertAndSelect(peer)
window.openAssistantInSidebar(peer.id)
window.showsSidebar = true
other.open(Workspace(pane: Pane(.empty)))
other.sidebarMode = .assistant
other.showsSidebar = true
#expect(app.isAssistantNotificationFocused(peer.id))
#expect(app.isAssistantNotificationFocused(conversation.id))
#expect(!app.isAssistantNotificationFocused(UUID()))
#expect(other.assistantLibrary.conversation(id: peer.id) === peer)
other.isAssistantArchivePresented = true
#expect(!other.isShowingAssistant(conversation.id))
let target = try #require(app.selectAssistantNotificationTarget(UUID()))
#expect(target.selectedDestination == .assistant)
#expect(target.assistantLibrary.selectedConversation == nil)
}
@Test func ghosttyValuesAlreadyPublishedAreNotReplayedWhenObserving() async {
let state = TerminalViewState(configSource: .none)
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1)
try? await Task.sleep(for: .milliseconds(30))
let monitor = TerminalCommandMonitor()
monitor.beginConnection()
let observation = TerminalCommandObservation(state: state, monitor: monitor)
#expect(!monitor.hasUnreadCompletion)
monitor.endConnection()
state.terminalDidFinishCommand(exitCode: 0, durationNanos: 1)
try? await Task.sleep(for: .milliseconds(30))
#expect(!monitor.hasUnreadCompletion)
withExtendedLifetime(observation) {}
}
}