vibetunnel/ios/VibeTunnelTests/SessionListViewModelTests.swift
Igor Tarasenko 9fad6301a0
feat: Add Bonjour/mDNS service discovery for iOS app (#226)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2025-07-05 11:34:36 +01:00

642 lines
22 KiB
Swift

import Foundation
import Testing
@testable import VibeTunnel
/// Comprehensive tests for SessionListViewModel
///
/// Tests all functionality including:
/// - Session loading and management
/// - Search and filtering
/// - UI state management
/// - Network connectivity handling
/// - Error handling
/// - Session operations (kill, cleanup)
@MainActor
struct SessionListViewModelTests {
// MARK: - Mock Dependencies - Use shared mocks from Mocks/
/// Test-specific subclass to inject mock ConnectionManager
class TestableSessionListViewModel: SessionListViewModel {
private let mockConnectionManager: MockConnectionManager
init(
sessionService: SessionServiceProtocol,
networkMonitor: NetworkMonitoring,
connectionManager: MockConnectionManager
) {
self.mockConnectionManager = connectionManager
super.init(sessionService: sessionService, networkMonitor: networkMonitor)
}
override func disconnect() async {
await mockConnectionManager.disconnect()
}
}
// MARK: - Helper Methods
func createMockSession(id: String, name: String? = nil, isRunning: Bool = true, pid: Int? = nil) -> Session {
Session(
id: id,
command: ["bash"],
workingDir: "/Users/test",
name: name,
status: isRunning ? .running : .exited,
exitCode: isRunning ? nil : 0,
startedAt: "2024-01-01T12:00:00Z",
lastModified: "2024-01-01T12:00:00Z",
pid: pid,
width: 80,
height: 24,
waiting: false,
source: nil,
remoteId: nil,
remoteName: nil,
remoteUrl: nil
)
}
func createViewModel(
mockSessionService: MockSessionService = MockSessionService(),
mockNetworkMonitor: MockNetworkMonitor = MockNetworkMonitor()
)
-> (SessionListViewModel, MockConnectionManager)
{
let mockConnectionManager = MockConnectionManager()
let viewModel = TestableSessionListViewModel(
sessionService: mockSessionService,
networkMonitor: mockNetworkMonitor,
connectionManager: mockConnectionManager
)
return (viewModel, mockConnectionManager)
}
// MARK: - Initialization Tests
@Test("ViewModel initializes with correct default state")
func initialState() async {
let (viewModel, _) = createViewModel()
#expect(viewModel.sessions.isEmpty)
#expect(viewModel.filteredSessions.isEmpty)
#expect(viewModel.isLoading == false)
#expect(viewModel.errorMessage == nil)
#expect(viewModel.showExitedSessions == true)
#expect(viewModel.searchText.isEmpty)
#expect(viewModel.isNetworkConnected == true)
// UI State
#expect(viewModel.showingCreateSession == false)
#expect(viewModel.selectedSession == nil)
#expect(viewModel.showingFileBrowser == false)
#expect(viewModel.showingSettings == false)
#expect(viewModel.showingCastImporter == false)
#expect(viewModel.importedCastFile == nil)
#expect(viewModel.presentedError == nil)
#expect(viewModel.enableLivePreviews == true)
}
// MARK: - Session Loading Tests
@Test("loadSessions successfully loads and sets sessions")
func loadSessionsSuccess() async {
let mockService = MockSessionService()
let mockSessions = [
createMockSession(id: "1", name: "Test Session 1"),
createMockSession(id: "2", name: "Test Session 2", isRunning: false)
]
mockService.sessions = mockSessions
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
#expect(mockService.getSessionsCallCount == 1)
#expect(viewModel.sessions.count == 2)
#expect(viewModel.sessions[0].id == "1")
#expect(viewModel.sessions[1].id == "2")
#expect(viewModel.errorMessage == nil)
#expect(viewModel.isLoading == false)
}
@Test("loadSessions shows loading state during first load")
func loadSessionsLoadingState() async {
let mockService = MockSessionService()
let (viewModel, _) = createViewModel(mockSessionService: mockService)
// Verify initial state
#expect(viewModel.isLoading == false)
#expect(viewModel.sessions.isEmpty)
// Load sessions and verify loading state is cleared
await viewModel.loadSessions()
#expect(viewModel.isLoading == false)
}
@Test("loadSessions doesn't show loading when sessions already exist")
func loadSessionsNoLoadingWhenSessionsExist() async {
let mockService = MockSessionService()
let existingSessions = [createMockSession(id: "1")]
mockService.sessions = existingSessions
let (viewModel, _) = createViewModel(mockSessionService: mockService)
// Load initial sessions
await viewModel.loadSessions()
#expect(viewModel.sessions.count == 1)
// Add more sessions and reload
mockService.sessions.append(createMockSession(id: "2"))
await viewModel.loadSessions()
// Should not have shown loading state since sessions weren't empty
#expect(viewModel.isLoading == false)
#expect(viewModel.sessions.count == 2)
}
@Test("loadSessions handles error correctly")
func loadSessionsError() async {
let mockService = MockSessionService()
mockService.shouldThrowError = true
mockService.thrownError = APIError.serverError(500, "Test error")
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
#expect(mockService.getSessionsCallCount == 1)
#expect(viewModel.sessions.isEmpty)
#expect(viewModel.errorMessage != nil)
#expect(viewModel.isLoading == false)
}
// MARK: - Filtering Tests
@Test("filteredSessions shows all sessions when showExitedSessions is true")
func filteredSessionsShowAll() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "1", name: "Running", isRunning: true),
createMockSession(id: "2", name: "Exited", isRunning: false)
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
viewModel.showExitedSessions = true
#expect(viewModel.filteredSessions.count == 2)
#expect(viewModel.filteredSessions.contains { $0.id == "1" })
#expect(viewModel.filteredSessions.contains { $0.id == "2" })
}
@Test("filteredSessions hides exited sessions when showExitedSessions is false")
func filteredSessionsHideExited() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "1", name: "Running", isRunning: true),
createMockSession(id: "2", name: "Exited", isRunning: false)
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
viewModel.showExitedSessions = false
#expect(viewModel.filteredSessions.count == 1)
#expect(viewModel.filteredSessions[0].id == "1")
}
// MARK: - Search Tests
@Test("filteredSessions filters by session name")
func searchByName() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "1", name: "Frontend Dev"),
createMockSession(id: "2", name: "Backend API"),
createMockSession(id: "3", name: "Database Work")
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
viewModel.searchText = "front"
#expect(viewModel.filteredSessions.count == 1)
#expect(viewModel.filteredSessions[0].id == "1")
}
@Test("filteredSessions filters by command")
func searchByCommand() async {
let mockService = MockSessionService()
mockService.sessions = [
Session(
id: "1",
command: ["npm", "run", "dev"],
workingDir: "/",
name: nil,
status: .running,
exitCode: nil,
startedAt: "2024-01-01T12:00:00Z",
lastModified: nil,
pid: 1,
width: 80,
height: 24,
waiting: false,
source: nil,
remoteId: nil,
remoteName: nil,
remoteUrl: nil
),
Session(
id: "2",
command: ["python", "server.py"],
workingDir: "/",
name: nil,
status: .running,
exitCode: nil,
startedAt: "2024-01-01T12:00:00Z",
lastModified: nil,
pid: 2,
width: 80,
height: 24,
waiting: false,
source: nil,
remoteId: nil,
remoteName: nil,
remoteUrl: nil
)
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
viewModel.searchText = "npm"
#expect(viewModel.filteredSessions.count == 1)
#expect(viewModel.filteredSessions[0].id == "1")
}
@Test("filteredSessions filters by working directory")
func searchByWorkingDir() async {
let mockService = MockSessionService()
mockService.sessions = [
Session(
id: "1",
command: ["bash"],
workingDir: "/home/user/frontend",
name: nil,
status: .running,
exitCode: nil,
startedAt: "2024-01-01T12:00:00Z",
lastModified: nil,
pid: 1,
width: 80,
height: 24,
waiting: false,
source: nil,
remoteId: nil,
remoteName: nil,
remoteUrl: nil
),
Session(
id: "2",
command: ["bash"],
workingDir: "/home/user/backend",
name: nil,
status: .running,
exitCode: nil,
startedAt: "2024-01-01T12:00:00Z",
lastModified: nil,
pid: 2,
width: 80,
height: 24,
waiting: false,
source: nil,
remoteId: nil,
remoteName: nil,
remoteUrl: nil
)
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
viewModel.searchText = "frontend"
#expect(viewModel.filteredSessions.count == 1)
#expect(viewModel.filteredSessions[0].id == "1")
}
@Test("filteredSessions filters by PID")
func searchByPID() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "1", name: "Session 1", pid: 1_234),
createMockSession(id: "2", name: "Session 2", pid: 5_678)
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
viewModel.searchText = "1234"
#expect(viewModel.filteredSessions.count == 1)
#expect(viewModel.filteredSessions[0].id == "1")
}
@Test("filteredSessions returns all when search is empty")
func searchEmpty() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "1", name: "Session 1"),
createMockSession(id: "2", name: "Session 2")
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
viewModel.searchText = ""
#expect(viewModel.filteredSessions.count == 2)
}
@Test("search is case insensitive")
func searchCaseInsensitive() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "1", name: "Frontend Development")
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
viewModel.searchText = "FRONTEND"
#expect(viewModel.filteredSessions.count == 1)
#expect(viewModel.filteredSessions[0].id == "1")
}
// MARK: - Network Connectivity Tests
@Test("isNetworkConnected reflects network monitor state")
func networkConnectivity() async {
let mockNetworkMonitor = MockNetworkMonitor(isConnected: true)
let (viewModel, _) = createViewModel(mockNetworkMonitor: mockNetworkMonitor)
#expect(viewModel.isNetworkConnected == true)
mockNetworkMonitor.simulateStateChange(to: false)
#expect(viewModel.isNetworkConnected == false)
mockNetworkMonitor.simulateStateChange(to: true)
#expect(viewModel.isNetworkConnected == true)
}
// MARK: - Session Operations Tests
@Test("killSession calls service and reloads sessions")
func killSessionSuccess() async {
let mockService = MockSessionService()
mockService.sessions = [createMockSession(id: "test-session")]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
await viewModel.killSession("test-session")
#expect(mockService.killSessionCallCount == 1)
#expect(mockService.killedSessionIds.contains("test-session"))
#expect(mockService.getSessionsCallCount == 2) // Initial load + reload after kill
#expect(viewModel.errorMessage == nil)
}
@Test("killSession handles error correctly")
func killSessionError() async {
let mockService = MockSessionService()
mockService.shouldThrowError = true
mockService.thrownError = APIError.serverError(500, "Kill failed")
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.killSession("test-session")
#expect(mockService.killSessionCallCount == 1)
#expect(viewModel.errorMessage != nil)
}
@Test("cleanupSession calls service and reloads sessions")
func cleanupSessionSuccess() async {
let mockService = MockSessionService()
mockService.sessions = [createMockSession(id: "test-session", isRunning: false)]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
await viewModel.cleanupSession("test-session")
#expect(mockService.cleanupSessionCallCount == 1)
#expect(mockService.cleanedUpSessionIds.contains("test-session"))
#expect(mockService.getSessionsCallCount == 2) // Initial load + reload after cleanup
#expect(viewModel.errorMessage == nil)
}
@Test("cleanupSession handles error correctly")
func cleanupSessionError() async {
let mockService = MockSessionService()
mockService.shouldThrowError = true
mockService.thrownError = APIError.serverError(500, "Cleanup failed")
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.cleanupSession("test-session")
#expect(mockService.cleanupSessionCallCount == 1)
#expect(viewModel.errorMessage != nil)
}
@Test("cleanupAllExited calls service and reloads sessions")
func cleanupAllExitedSuccess() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "running", isRunning: true),
createMockSession(id: "exited1", isRunning: false),
createMockSession(id: "exited2", isRunning: false)
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
await viewModel.cleanupAllExited()
#expect(mockService.cleanupAllExitedCallCount == 1)
#expect(mockService.getSessionsCallCount == 2) // Initial load + reload after cleanup
#expect(viewModel.errorMessage == nil)
}
@Test("cleanupAllExited handles error correctly")
func cleanupAllExitedError() async {
let mockService = MockSessionService()
mockService.shouldThrowError = true
mockService.thrownError = APIError.serverError(500, "Cleanup all failed")
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.cleanupAllExited()
#expect(mockService.cleanupAllExitedCallCount == 1)
#expect(viewModel.errorMessage != nil)
}
@Test("killAllSessions calls service and reloads sessions")
func killAllSessionsSuccess() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "session1"),
createMockSession(id: "session2")
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
await viewModel.killAllSessions()
#expect(mockService.killAllSessionsCallCount == 1)
#expect(mockService.getSessionsCallCount == 2) // Initial load + reload after kill all
#expect(viewModel.errorMessage == nil)
}
@Test("killAllSessions handles error correctly")
func killAllSessionsError() async {
let mockService = MockSessionService()
mockService.shouldThrowError = true
mockService.thrownError = APIError.serverError(500, "Kill all failed")
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.killAllSessions()
#expect(mockService.killAllSessionsCallCount == 1)
#expect(viewModel.errorMessage != nil)
}
// MARK: - Connection Management Tests
@Test("disconnect calls connection manager")
func testDisconnect() async {
let (viewModel, mockConnectionManager) = createViewModel()
await viewModel.disconnect()
#expect(mockConnectionManager.disconnectCallCount == 1)
}
// MARK: - UI State Tests
@Test("UI state properties can be modified")
func uIStateManagement() async {
let (viewModel, _) = createViewModel()
// Test all UI state properties
viewModel.showingCreateSession = true
#expect(viewModel.showingCreateSession == true)
let mockSession = createMockSession(id: "test")
viewModel.selectedSession = mockSession
#expect(viewModel.selectedSession?.id == "test")
viewModel.showingFileBrowser = true
#expect(viewModel.showingFileBrowser == true)
viewModel.showingSettings = true
#expect(viewModel.showingSettings == true)
viewModel.showingCastImporter = true
#expect(viewModel.showingCastImporter == true)
let mockCastFile = CastFileItem(url: URL(string: "file://test.cast")!)
viewModel.importedCastFile = mockCastFile
#expect(viewModel.importedCastFile?.url.absoluteString == "file://test.cast")
let mockError = IdentifiableError(error: APIError.networkError(URLError(.notConnectedToInternet)))
viewModel.presentedError = mockError
#expect(viewModel.presentedError != nil)
viewModel.enableLivePreviews = false
#expect(viewModel.enableLivePreviews == false)
}
// MARK: - Complex Scenarios
@Test("search and filter work together correctly")
func searchAndFilterCombined() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "1", name: "Frontend Running", isRunning: true),
createMockSession(id: "2", name: "Frontend Exited", isRunning: false),
createMockSession(id: "3", name: "Backend Running", isRunning: true),
createMockSession(id: "4", name: "Backend Exited", isRunning: false)
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
// Search for "Frontend" and hide exited sessions
viewModel.searchText = "Frontend"
viewModel.showExitedSessions = false
#expect(viewModel.filteredSessions.count == 1)
#expect(viewModel.filteredSessions[0].id == "1")
#expect(viewModel.filteredSessions[0].name == "Frontend Running")
}
@Test("error handling preserves previous sessions on failure")
func errorPreservesData() async {
let mockService = MockSessionService()
mockService.sessions = [createMockSession(id: "existing")]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
// First successful load
await viewModel.loadSessions()
#expect(viewModel.sessions.count == 1)
#expect(viewModel.errorMessage == nil)
// Second load fails
mockService.shouldThrowError = true
await viewModel.loadSessions()
// Sessions should remain unchanged, but error should be set
#expect(viewModel.sessions.count == 1) // Previous data preserved
#expect(viewModel.errorMessage != nil) // Error is shown
}
@Test("multiple concurrent operations handle correctly")
func concurrentOperations() async {
let mockService = MockSessionService()
mockService.sessions = [
createMockSession(id: "1"),
createMockSession(id: "2")
]
let (viewModel, _) = createViewModel(mockSessionService: mockService)
await viewModel.loadSessions()
// Start multiple operations concurrently
async let killResult: () = viewModel.killSession("1")
async let cleanupResult: () = viewModel.cleanupSession("2")
async let loadResult: () = viewModel.loadSessions()
// Wait for all to complete
await killResult
await cleanupResult
await loadResult
// Verify all operations were called
#expect(mockService.killSessionCallCount == 1)
#expect(mockService.cleanupSessionCallCount == 1)
#expect(mockService.getSessionsCallCount >= 3) // At least initial + reloads
}
}