Skip to content

Commit 32f8267

Browse files
committed
add tests
1 parent eb6de41 commit 32f8267

File tree

8 files changed

+415
-63
lines changed

8 files changed

+415
-63
lines changed

app/modules/features/Chat/Sources/ChatViewModel.swift

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,16 @@ public class ChatViewModel {
5454
self.selectedTab = selectedTab
5555
self.defaultMode = defaultMode
5656

57-
Task {
58-
await appEventHandlerRegistry.registerHandler { [weak self] event in
59-
guard let self else { return false }
60-
if let event = event as? AddCodeToChatEvent {
61-
await handle(addCodeToChatEvent: event)
62-
return true
63-
} else if event is NewChatEvent {
64-
await addTab(copyingCurrentInput: true)
65-
return true
66-
} else {
67-
return false
68-
}
57+
appEventHandlerRegistry.registerHandler { [weak self] event in
58+
guard let self else { return false }
59+
if let event = event as? AddCodeToChatEvent {
60+
await handle(addCodeToChatEvent: event)
61+
return true
62+
} else if event is NewChatEvent {
63+
await addTab(copyingCurrentInput: true)
64+
return true
65+
} else {
66+
return false
6967
}
7068
}
7169
}
@@ -110,9 +108,11 @@ public class ChatViewModel {
110108

111109
private func handle(addCodeToChatEvent event: AddCodeToChatEvent) {
112110
Task { @MainActor in
113-
NSApp.setActivationPolicy(.regular)
114-
// TODO: make sure the app is activated. Sometimes it doesn't work.
115-
Task { try await NSApplication.activateCurrentApp() }
111+
if ProcessInfo.processInfo.processName != "xctest" {
112+
NSApp.setActivationPolicy(.regular)
113+
// TODO: make sure the app is activated. Sometimes it doesn't work.
114+
Task { try await NSApplication.activateCurrentApp() }
115+
}
116116

117117
if event.newThread {
118118
self.addTab()
Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
// Copyright Xcompanion. All rights reserved.
2+
// Licensed under the XXX License. See License.txt in the project root for license information.
3+
4+
import AccessibilityFoundation
5+
import AppEventServiceInterface
6+
import AppKit
7+
import ChatAppEvents
8+
import ChatFoundation
9+
import Combine
10+
import Dependencies
11+
import Foundation
12+
import FoundationInterfaces
13+
import LLMServiceInterface
14+
import SettingsServiceInterface
15+
import SwiftTesting
16+
import Testing
17+
import XcodeObserverServiceInterface
18+
@testable import Chat
19+
20+
// MARK: - ChatViewModelTests
21+
22+
struct ChatViewModelTests {
23+
let dummyAXElement = AnyAXUIElement(AXUIElementCreateApplication(0))
24+
25+
@MainActor
26+
@Test("initializing with default parameters creates a tab with default mode")
27+
func test_initialization_withDefaultParameters() {
28+
let viewModel = ChatViewModel()
29+
30+
#expect(viewModel.tabs.count == 1)
31+
#expect(viewModel.selectedTab == viewModel.tabs.first)
32+
#expect(viewModel.defaultMode == .agent)
33+
#expect(viewModel.currentModel == .claudeSonnet)
34+
}
35+
36+
@MainActor
37+
@Test("initializing with custom mode uses that mode")
38+
func test_initialization_withCustomMode() {
39+
let viewModel = ChatViewModel(defaultMode: .ask)
40+
41+
#expect(viewModel.defaultMode == .ask)
42+
}
43+
44+
@MainActor
45+
@Test("debug initializer with custom tabs and selected tab")
46+
func test_debugInitializer_withCustomTabsAndSelectedTab() {
47+
let tab1 = ChatTabViewModel()
48+
let tab2 = ChatTabViewModel()
49+
let tabs = [tab1, tab2]
50+
51+
let viewModel = ChatViewModel(
52+
defaultMode: .ask,
53+
tabs: tabs,
54+
currentModel: .gpt4o,
55+
selectedTab: tab2)
56+
57+
#expect(viewModel.tabs.count == 2)
58+
#expect(viewModel.tabs[0] == tab1)
59+
#expect(viewModel.tabs[1] == tab2)
60+
#expect(viewModel.selectedTab == tab2)
61+
#expect(viewModel.defaultMode == .ask)
62+
#expect(viewModel.currentModel == .gpt4o)
63+
}
64+
65+
@MainActor
66+
@Test("adding a new tab increases tab count when current tab is not empty")
67+
func test_addTab_increasesTabCount() async {
68+
let viewModel = withAllModelAvailable {
69+
ChatViewModel()
70+
}
71+
viewModel.selectedTab?.input.textInput = TextInput([.text("Test input")])
72+
await viewModel.selectedTab?.sendMessage()
73+
#expect(viewModel.tabs.count == 1)
74+
viewModel.addTab()
75+
76+
#expect(viewModel.tabs.count == 2)
77+
#expect(viewModel.selectedTab == viewModel.tabs.last)
78+
}
79+
80+
@MainActor
81+
@Test("adding a new tab when current tab is empty replaces it")
82+
func test_addTab_replacesEmptyTab() {
83+
let viewModel = withAllModelAvailable {
84+
ChatViewModel(tabs: [.init()])
85+
}
86+
let initialTab = viewModel.tabs.first
87+
88+
// Ensure the initial tab is empty
89+
#expect(initialTab?.events.isEmpty == true)
90+
#expect(viewModel.tabs.count == 1)
91+
viewModel.addTab()
92+
93+
#expect(viewModel.tabs.count == 1)
94+
#expect(viewModel.selectedTab != initialTab)
95+
}
96+
97+
@MainActor
98+
@Test("adding a new tab with copyingCurrentInput copies the input")
99+
func test_addTab_withCopyingCurrentInput() {
100+
let viewModel = ChatViewModel()
101+
let initialTab = viewModel.selectedTab
102+
103+
// Set some input on the initial tab
104+
initialTab?.input.textInput = TextInput([.text("Test input")])
105+
106+
viewModel.addTab(copyingCurrentInput: true)
107+
108+
#expect(viewModel.selectedTab?.input.textInput.string.string == "Test input")
109+
}
110+
111+
@MainActor
112+
@Test("removing a tab decreases tab count")
113+
func test_removeTab_decreasesTabCount() {
114+
// Create a viewModel with multiple tabs
115+
let tab1 = ChatTabViewModel()
116+
let tab2 = ChatTabViewModel()
117+
let tabs = [tab1, tab2]
118+
119+
let viewModel = ChatViewModel(tabs: tabs)
120+
121+
let initialTabCount = viewModel.tabs.count
122+
123+
viewModel.remove(tab: tab2)
124+
125+
#expect(viewModel.tabs.count == initialTabCount - 1)
126+
#expect(!viewModel.tabs.contains(tab2))
127+
}
128+
129+
@MainActor
130+
@Test("removing the selected tab selects the first tab")
131+
func test_removeTab_removingSelectedTab() {
132+
// Create a viewModel with multiple tabs
133+
let tab1 = ChatTabViewModel()
134+
let tab2 = ChatTabViewModel()
135+
let tabs = [tab1, tab2]
136+
137+
let viewModel = ChatViewModel(tabs: tabs, selectedTab: tab2)
138+
139+
viewModel.remove(tab: tab2)
140+
141+
#expect(viewModel.selectedTab == tab1)
142+
}
143+
144+
@MainActor
145+
@Test("removing the last tab creates a new one")
146+
func test_removeTab_removingLastTab() throws {
147+
let viewModel = ChatViewModel()
148+
let initialTab = try #require(viewModel.tabs.first)
149+
150+
#expect(viewModel.tabs.count == 1)
151+
viewModel.remove(tab: initialTab)
152+
153+
#expect(viewModel.tabs.count == 1)
154+
#expect(viewModel.tabs.first != initialTab)
155+
}
156+
157+
@MainActor
158+
@Test("handling NewChatEvent adds a new tab")
159+
func test_handleNewChatEvent() async {
160+
let mockAppEventHandlerRegistry = MockAppEventHandlerRegistry()
161+
162+
let viewModel = withAllModelAvailable {
163+
withDependencies {
164+
$0.appEventHandlerRegistry = mockAppEventHandlerRegistry
165+
} operation: {
166+
ChatViewModel()
167+
}
168+
}
169+
170+
viewModel.selectedTab?.input.textInput = TextInput([.text("Test input")])
171+
await viewModel.selectedTab?.sendMessage()
172+
#expect(viewModel.tabs.count == 1)
173+
#expect(viewModel.tabs.first?.events.count == 1)
174+
175+
let handled = await mockAppEventHandlerRegistry.handle(event: NewChatEvent())
176+
#expect(handled == true)
177+
178+
// Verify a new tab was added
179+
#expect(viewModel.tabs.count == 2)
180+
}
181+
182+
@MainActor
183+
@Test("handling AddCodeToChatEvent with newThread creates a new tab")
184+
func test_handleAddCodeToChatEvent_withNewThread() async {
185+
let mockAppEventHandlerRegistry = MockAppEventHandlerRegistry()
186+
let mockXcodeObserver = MockXcodeObserver(AXState<XcodeState>.unknown)
187+
188+
let viewModel = withAllModelAvailable { withDependencies {
189+
$0.appEventHandlerRegistry = mockAppEventHandlerRegistry
190+
$0.xcodeObserver = mockXcodeObserver
191+
} operation: {
192+
ChatViewModel()
193+
}
194+
}
195+
viewModel.selectedTab?.input.textInput = TextInput([.text("Test input")])
196+
await viewModel.selectedTab?.sendMessage()
197+
#expect(viewModel.tabs.count == 1)
198+
#expect(viewModel.tabs.first?.events.count == 1)
199+
200+
let handled = await mockAppEventHandlerRegistry.handle(event: AddCodeToChatEvent(newThread: true, chatMode: .ask))
201+
#expect(handled == true)
202+
203+
// Verify a new tab was added
204+
#expect(viewModel.tabs.count == 2)
205+
}
206+
207+
@MainActor
208+
@Test("handling AddCodeToChatEvent with chatMode updates the selected tab's mode")
209+
func test_handleAddCodeToChatEvent_withChatMode() async {
210+
let mockAppEventHandlerRegistry = MockAppEventHandlerRegistry()
211+
let mockXcodeObserver = MockXcodeObserver(AXState<XcodeState>.unknown)
212+
213+
let viewModel = withDependencies {
214+
$0.appEventHandlerRegistry = mockAppEventHandlerRegistry
215+
$0.xcodeObserver = mockXcodeObserver
216+
} operation: {
217+
ChatViewModel()
218+
}
219+
220+
let handled = await mockAppEventHandlerRegistry.handle(event: AddCodeToChatEvent(newThread: false, chatMode: .ask))
221+
#expect(handled == true)
222+
223+
// Verify the selected tab's mode was updated
224+
#expect(viewModel.selectedTab?.input.mode == .ask)
225+
}
226+
227+
@MainActor
228+
@Test("addCodeSelection adds file attachment when no editor is focused")
229+
func test_addCodeSelection_addsFileAttachment() async throws {
230+
let documentURL = try #require(URL(string: "file:///test/file.swift"))
231+
let documentContent = "Test file content"
232+
let mockFileManager = MockFileManager(files: [
233+
documentURL.path(): documentContent,
234+
])
235+
let mockAppEventHandlerRegistry = MockAppEventHandlerRegistry()
236+
237+
let xcodeState = XcodeState(
238+
activeApplicationProcessIdentifier: 123,
239+
previousApplicationProcessIdentifier: nil,
240+
xcodesState: [
241+
XcodeAppState(
242+
processIdentifier: 123,
243+
isActive: true,
244+
workspaces: [XcodeWorkspaceState(
245+
axElement: dummyAXElement,
246+
url: URL(string: "file:///test/project.xcodeproj")!,
247+
editors: [],
248+
isFocused: true,
249+
document: documentURL,
250+
tabs: [])]),
251+
])
252+
let mockXcodeObserver = MockXcodeObserver(AXState<XcodeState>.state(xcodeState))
253+
254+
let viewModel = withDependencies {
255+
$0.xcodeObserver = mockXcodeObserver
256+
$0.fileManager = mockFileManager
257+
$0.appEventHandlerRegistry = mockAppEventHandlerRegistry
258+
} operation: {
259+
ChatViewModel()
260+
}
261+
262+
// Call the method through the event handler
263+
let handled = await mockAppEventHandlerRegistry.handle(event: AddCodeToChatEvent(newThread: false, chatMode: nil))
264+
#expect(handled)
265+
// Verify a file attachment was added
266+
#expect(viewModel.selectedTab?.input.attachments.count == 1)
267+
if case .file(let fileAttachment) = viewModel.selectedTab?.input.attachments.first {
268+
#expect(fileAttachment.path == documentURL)
269+
#expect(fileAttachment.content == documentContent)
270+
} else {
271+
Issue.record("Expected a file attachment")
272+
}
273+
}
274+
275+
@MainActor
276+
@Test("addCodeSelection adds file selection attachment when editor has selection")
277+
func test_addCodeSelection_addsFileSelectionAttachment() async throws {
278+
let filePath = try #require(URL(string: "file:///test/file.swift"))
279+
let content = "Test file content"
280+
let mockFileManager = MockFileManager(files: [
281+
filePath.path(): content,
282+
])
283+
let mockAppEventHandlerRegistry = MockAppEventHandlerRegistry()
284+
285+
let xcodeState = XcodeState(
286+
activeApplicationProcessIdentifier: 123,
287+
previousApplicationProcessIdentifier: nil,
288+
xcodesState: [
289+
XcodeAppState(
290+
processIdentifier: 123,
291+
isActive: true,
292+
workspaces: [XcodeWorkspaceState(
293+
axElement: dummyAXElement,
294+
url: URL(string: "file:///test/project.xcodeproj")!,
295+
editors: [XcodeEditorState(
296+
fileName: filePath.lastPathComponent,
297+
isFocused: true,
298+
content: content,
299+
selections: [CursorRange(start: CursorPosition(line: 0, character: 0), end: CursorPosition(line: 1, character: 5))],
300+
compilerMessages: [])],
301+
isFocused: true,
302+
document: filePath,
303+
tabs: [XcodeWorkspaceState.Tab(
304+
fileName: "file.swift",
305+
isFocused: true,
306+
knownPath: filePath,
307+
lastKnownContent: content)])]),
308+
])
309+
let mockXcodeObserver = MockXcodeObserver(AXState<XcodeState>.state(xcodeState))
310+
311+
let viewModel = withDependencies {
312+
$0.xcodeObserver = mockXcodeObserver
313+
$0.fileManager = mockFileManager
314+
$0.appEventHandlerRegistry = mockAppEventHandlerRegistry
315+
} operation: {
316+
ChatViewModel()
317+
}
318+
319+
let handled = await mockAppEventHandlerRegistry.handle(event: AddCodeToChatEvent(newThread: false, chatMode: nil))
320+
#expect(handled)
321+
322+
// Verify a file selection attachment was added
323+
#expect(viewModel.selectedTab?.input.attachments.count == 1)
324+
if case .fileSelection(let selectionAttachment) = viewModel.selectedTab?.input.attachments.first {
325+
#expect(selectionAttachment.file.path == filePath)
326+
#expect(selectionAttachment.file.content == content)
327+
#expect(selectionAttachment.startLine == 1) // 0-based to 1-based
328+
#expect(selectionAttachment.endLine == 2) // 0-based to 1-based
329+
} else {
330+
Issue.record("Expected a file selection attachment")
331+
}
332+
}
333+
334+
/// Setup the settings and used default to allow for messages to be sent (there need to be an LLM model configured).
335+
private func withAllModelAvailable<R>(
336+
operation: () -> R)
337+
-> R
338+
{
339+
let settingsService = MockSettingsService.allConfigured
340+
let mockUserDefaults = MockUserDefaults(initialValues: [
341+
"selectedLLMModel": "gpt-4o",
342+
])
343+
return withDependencies({
344+
$0.settingsService = settingsService
345+
$0.userDefaults = mockUserDefaults
346+
}) {
347+
operation()
348+
}
349+
}
350+
}

0 commit comments

Comments
 (0)