Skip to content

Commit e0c37ae

Browse files
grokifyclaude
andcommitted
test(desktop): add mocks for dependency injection testing
Create mock implementations of DI protocols for isolated unit testing. - Add MockCommandExecutor with stubbing and verification helpers - Add MockFileSystem with in-memory storage and test data builders Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent bc46346 commit e0c37ae

File tree

2 files changed

+293
-0
lines changed

2 files changed

+293
-0
lines changed
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import Foundation
2+
@testable import PlexusOneDesktop
3+
4+
/// Mock implementation of CommandExecuting for testing
5+
final class MockCommandExecutor: CommandExecuting, @unchecked Sendable {
6+
/// Recorded calls for verification
7+
private(set) var executedCommands: [(path: String, arguments: [String])] = []
8+
9+
/// Stub responses keyed by command path or pattern
10+
private var stubbedResults: [String: CommandResult] = [:]
11+
12+
/// Default result when no stub matches
13+
var defaultResult: CommandResult = CommandResult(
14+
exitCode: 0,
15+
stdout: "",
16+
stderr: ""
17+
)
18+
19+
/// Error to throw (if set, takes precedence over results)
20+
var errorToThrow: Error?
21+
22+
// MARK: - Stubbing
23+
24+
/// Stub a result for a specific command path
25+
func stub(path: String, result: CommandResult) {
26+
stubbedResults[path] = result
27+
}
28+
29+
/// Stub a result for tmux commands (convenience method)
30+
func stubTmux(arguments: [String], result: CommandResult) {
31+
// Store by the first tmux argument as key
32+
let key = arguments.first ?? "tmux"
33+
stubbedResults["tmux:\(key)"] = result
34+
}
35+
36+
/// Stub tmux list-sessions output
37+
func stubListSessions(_ output: String, exitCode: Int32 = 0) {
38+
stubbedResults["tmux:list-sessions"] = CommandResult(
39+
exitCode: exitCode,
40+
stdout: output,
41+
stderr: ""
42+
)
43+
}
44+
45+
/// Stub tmux list-sessions with no server running
46+
func stubNoServerRunning() {
47+
stubbedResults["tmux:list-sessions"] = CommandResult(
48+
exitCode: 1,
49+
stdout: "",
50+
stderr: "no server running on /tmp/tmux-501/default"
51+
)
52+
}
53+
54+
/// Stub tmux command success
55+
func stubTmuxSuccess(arguments: [String], stdout: String = "") {
56+
let key = arguments.first ?? "tmux"
57+
stubbedResults["tmux:\(key)"] = CommandResult(
58+
exitCode: 0,
59+
stdout: stdout,
60+
stderr: ""
61+
)
62+
}
63+
64+
/// Stub tmux command failure
65+
func stubTmuxFailure(arguments: [String], stderr: String) {
66+
let key = arguments.first ?? "tmux"
67+
stubbedResults["tmux:\(key)"] = CommandResult(
68+
exitCode: 1,
69+
stdout: "",
70+
stderr: stderr
71+
)
72+
}
73+
74+
/// Stub `which tmux` command
75+
func stubWhichTmux(available: Bool) {
76+
stubbedResults["/usr/bin/which"] = CommandResult(
77+
exitCode: available ? 0 : 1,
78+
stdout: available ? "/opt/homebrew/bin/tmux\n" : "",
79+
stderr: ""
80+
)
81+
}
82+
83+
// MARK: - CommandExecuting
84+
85+
func execute(_ path: String, arguments: [String]) async throws -> CommandResult {
86+
// Record the call
87+
executedCommands.append((path: path, arguments: arguments))
88+
89+
// Throw error if configured
90+
if let error = errorToThrow {
91+
throw error
92+
}
93+
94+
// Check for exact path match
95+
if let result = stubbedResults[path] {
96+
return result
97+
}
98+
99+
// Check for tmux-prefixed match (when tmux is in path or arguments)
100+
if path.contains("tmux") || arguments.first == "tmux" {
101+
let tmuxArg = path.contains("tmux") ? arguments.first : arguments.dropFirst().first
102+
if let arg = tmuxArg, let result = stubbedResults["tmux:\(arg)"] {
103+
return result
104+
}
105+
}
106+
107+
return defaultResult
108+
}
109+
110+
// MARK: - Verification
111+
112+
/// Reset all recorded calls and stubs
113+
func reset() {
114+
executedCommands = []
115+
stubbedResults = [:]
116+
errorToThrow = nil
117+
}
118+
119+
/// Check if a command was executed
120+
func wasExecuted(path: String) -> Bool {
121+
executedCommands.contains { $0.path == path }
122+
}
123+
124+
/// Check if a command was executed with specific arguments
125+
func wasExecuted(path: String, arguments: [String]) -> Bool {
126+
executedCommands.contains { $0.path == path && $0.arguments == arguments }
127+
}
128+
129+
/// Get number of times a command path was executed
130+
func executionCount(path: String) -> Int {
131+
executedCommands.filter { $0.path == path }.count
132+
}
133+
134+
/// Get number of times tmux was called with a specific subcommand
135+
func tmuxExecutionCount(subcommand: String) -> Int {
136+
executedCommands.filter { cmd in
137+
(cmd.path.contains("tmux") && cmd.arguments.first == subcommand) ||
138+
(cmd.arguments.first == "tmux" && cmd.arguments.dropFirst().first == subcommand)
139+
}.count
140+
}
141+
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import Foundation
2+
@testable import PlexusOneDesktop
3+
4+
/// In-memory mock implementation of FileSystemAccessing for testing
5+
final class MockFileSystem: FileSystemAccessing {
6+
/// In-memory file storage
7+
private var files: [String: Data] = [:]
8+
9+
/// Track which directories have been "created"
10+
private var directories: Set<String> = []
11+
12+
/// Track removed files/directories
13+
private(set) var removedItems: [URL] = []
14+
15+
/// Track created directories
16+
private(set) var createdDirectories: [URL] = []
17+
18+
/// Error to throw on next operation (if set)
19+
var errorToThrow: Error?
20+
21+
/// Mock home directory
22+
var mockHomeDirectory: URL = URL(fileURLWithPath: "/tmp/mock-home")
23+
24+
// MARK: - FileSystemAccessing
25+
26+
var homeDirectoryForCurrentUser: URL {
27+
mockHomeDirectory
28+
}
29+
30+
func fileExists(atPath path: String) -> Bool {
31+
files[path] != nil || directories.contains(path)
32+
}
33+
34+
func contents(at url: URL) throws -> Data {
35+
if let error = errorToThrow {
36+
throw error
37+
}
38+
guard let data = files[url.path] else {
39+
throw NSError(domain: NSCocoaErrorDomain, code: NSFileReadNoSuchFileError)
40+
}
41+
return data
42+
}
43+
44+
func write(_ data: Data, to url: URL, options: Data.WritingOptions) throws {
45+
if let error = errorToThrow {
46+
throw error
47+
}
48+
files[url.path] = data
49+
}
50+
51+
func createDirectory(at url: URL, withIntermediateDirectories: Bool) throws {
52+
if let error = errorToThrow {
53+
throw error
54+
}
55+
directories.insert(url.path)
56+
createdDirectories.append(url)
57+
}
58+
59+
func removeItem(at url: URL) throws {
60+
if let error = errorToThrow {
61+
throw error
62+
}
63+
files.removeValue(forKey: url.path)
64+
directories.remove(url.path)
65+
removedItems.append(url)
66+
}
67+
68+
// MARK: - Test Helpers
69+
70+
/// Set file content for a given path
71+
func setFile(at path: String, content: Data) {
72+
files[path] = content
73+
}
74+
75+
/// Set file content with a string
76+
func setFile(at path: String, content: String) {
77+
files[path] = content.data(using: .utf8)
78+
}
79+
80+
/// Set file content at a URL
81+
func setFile(at url: URL, content: Data) {
82+
files[url.path] = content
83+
}
84+
85+
/// Get file content at a path
86+
func getFile(at path: String) -> Data? {
87+
files[path]
88+
}
89+
90+
/// Get file content at a URL
91+
func getFile(at url: URL) -> Data? {
92+
files[url.path]
93+
}
94+
95+
/// Reset all state
96+
func reset() {
97+
files = [:]
98+
directories = []
99+
removedItems = []
100+
createdDirectories = []
101+
errorToThrow = nil
102+
}
103+
104+
/// Check if directory was created
105+
func wasDirectoryCreated(at url: URL) -> Bool {
106+
createdDirectories.contains(url)
107+
}
108+
109+
/// Check if item was removed
110+
func wasRemoved(at url: URL) -> Bool {
111+
removedItems.contains(url)
112+
}
113+
}
114+
115+
// MARK: - Test Data Builders
116+
117+
extension MockFileSystem {
118+
/// Create valid v2 multi-window state JSON
119+
static func makeV2StateJSON(configs: [WindowConfig] = []) -> Data {
120+
let state = MultiWindowState(windows: configs)
121+
let encoder = JSONEncoder()
122+
encoder.dateEncodingStrategy = .iso8601
123+
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
124+
return (try? encoder.encode(state)) ?? Data()
125+
}
126+
127+
/// Create valid v1 legacy state JSON
128+
static func makeV1StateJSON(
129+
gridColumns: Int = 2,
130+
gridRows: Int = 1,
131+
paneAttachments: [String: String] = [:]
132+
) -> Data {
133+
let json = """
134+
{
135+
"gridColumns": \(gridColumns),
136+
"gridRows": \(gridRows),
137+
"paneAttachments": \(paneAttachmentsJSON(paneAttachments)),
138+
"savedAt": "2024-01-01T00:00:00Z",
139+
"version": 1
140+
}
141+
"""
142+
return json.data(using: .utf8) ?? Data()
143+
}
144+
145+
private static func paneAttachmentsJSON(_ attachments: [String: String]) -> String {
146+
if attachments.isEmpty {
147+
return "{}"
148+
}
149+
let pairs = attachments.map { "\"\($0.key)\": \"\($0.value)\"" }
150+
return "{\(pairs.joined(separator: ", "))}"
151+
}
152+
}

0 commit comments

Comments
 (0)