diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 3224e03..609bc3f 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -13,7 +13,7 @@ import os.log @MainActor class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { @Published var timerEngine: TimerEngine? - private let settingsManager: SettingsManager = .shared + private let serviceContainer: ServiceContainer private let windowManager: WindowManaging private var updateManager: UpdateManager? private var cancellables = Set() @@ -21,19 +21,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { // Logging manager private let logger = LoggingManager.shared - - // Smart Mode services - private var fullscreenService: FullscreenDetectionService? - private var idleService: IdleMonitoringService? - private var usageTrackingService: UsageTrackingService? + + // Convenience accessor for settings + private var settingsManager: any SettingsProviding { + serviceContainer.settingsManager + } override init() { + self.serviceContainer = ServiceContainer.shared self.windowManager = WindowManager.shared super.init() } /// Initializer for testing with injectable dependencies - init(windowManager: WindowManaging) { + init(serviceContainer: ServiceContainer, windowManager: WindowManaging) { + self.serviceContainer = serviceContainer self.windowManager = windowManager super.init() } @@ -46,9 +48,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { logger.configureLogging() logger.appLogger.info("🚀 Application did finish launching") - timerEngine = TimerEngine(settingsManager: settingsManager) + // Get timer engine from service container + timerEngine = serviceContainer.timerEngine - setupSmartModeServices() + // Setup smart mode services through container + serviceContainer.setupSmartModeServices() // Initialize update manager after onboarding is complete if settingsManager.settings.hasCompletedOnboarding { @@ -64,37 +68,21 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } - private func setupSmartModeServices() { - fullscreenService = FullscreenDetectionService() - idleService = IdleMonitoringService( - idleThresholdMinutes: settingsManager.settings.smartMode.idleThresholdMinutes - ) - usageTrackingService = UsageTrackingService( - resetThresholdMinutes: settingsManager.settings.smartMode.usageResetAfterMinutes - ) - - if let idleService = idleService { - usageTrackingService?.setupIdleMonitoring(idleService) - } - - // Connect services to timer engine - timerEngine?.setupSmartMode( - fullscreenService: fullscreenService, - idleService: idleService - ) - - // Observe smart mode settings changes - settingsManager.$settings + // Note: Smart mode setup is now handled by ServiceContainer + // Keeping this method for settings change observation + private func observeSmartModeSettings() { + settingsManager.settingsPublisher .map { $0.smartMode } .removeDuplicates() .sink { [weak self] smartMode in - self?.idleService?.updateThreshold(minutes: smartMode.idleThresholdMinutes) - self?.usageTrackingService?.updateResetThreshold( + guard let self = self else { return } + self.serviceContainer.idleService?.updateThreshold(minutes: smartMode.idleThresholdMinutes) + self.serviceContainer.usageTrackingService?.updateResetThreshold( minutes: smartMode.usageResetAfterMinutes) // Force state check when settings change to apply immediately - self?.fullscreenService?.forceUpdate() - self?.idleService?.forceUpdate() + self.serviceContainer.fullscreenService?.forceUpdate() + self.serviceContainer.idleService?.forceUpdate() } .store(in: &cancellables) } @@ -117,7 +105,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } private func observeSettingsChanges() { - settingsManager.$settings + settingsManager.settingsPublisher .sink { [weak self] settings in if settings.hasCompletedOnboarding && self?.hasStartedTimers == false { self?.startTimers() @@ -129,6 +117,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } .store(in: &cancellables) + + // Also observe smart mode settings + observeSmartModeSettings() } func applicationWillTerminate(_ notification: Notification) { diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index d8f7e37..e7443be 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -10,6 +10,8 @@ import SwiftUI @main struct GazeApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + // Note: SettingsManager.shared is used directly here for SwiftUI view updates + // AppDelegate uses ServiceContainer for dependency injection @StateObject private var settingsManager = SettingsManager.shared init() { diff --git a/Gaze/Services/MockWindowManager.swift b/Gaze/Services/MockWindowManager.swift new file mode 100644 index 0000000..3ecf1bb --- /dev/null +++ b/Gaze/Services/MockWindowManager.swift @@ -0,0 +1,160 @@ +// +// MockWindowManager.swift +// Gaze +// +// Mock implementation of WindowManaging for testing purposes. +// + +import SwiftUI + +/// Mock window manager that tracks window operations without creating actual windows. +/// Useful for unit testing UI flows and state management. +@MainActor +final class MockWindowManager: WindowManaging { + + // MARK: - State Tracking + + private(set) var isOverlayReminderVisible = false + private(set) var isSubtleReminderVisible = false + + // MARK: - Operation History + + struct WindowOperation { + let timestamp: Date + let operation: Operation + + enum Operation { + case showOverlayReminder + case showSubtleReminder + case dismissOverlayReminder + case dismissSubtleReminder + case dismissAllReminders + case showSettings(initialTab: Int) + case showOnboarding + } + } + + private(set) var operations: [WindowOperation] = [] + + // MARK: - Callbacks for Testing + + var onShowOverlayReminder: (() -> Void)? + var onShowSubtleReminder: (() -> Void)? + var onDismissOverlayReminder: (() -> Void)? + var onDismissSubtleReminder: (() -> Void)? + var onShowSettings: ((Int) -> Void)? + var onShowOnboarding: (() -> Void)? + + // MARK: - WindowManaging Implementation + + func showReminderWindow(_ content: Content, windowType: ReminderWindowType) { + let operation: WindowOperation.Operation + + switch windowType { + case .overlay: + isOverlayReminderVisible = true + operation = .showOverlayReminder + onShowOverlayReminder?() + case .subtle: + isSubtleReminderVisible = true + operation = .showSubtleReminder + onShowSubtleReminder?() + } + + operations.append(WindowOperation(timestamp: Date(), operation: operation)) + } + + func dismissOverlayReminder() { + isOverlayReminderVisible = false + operations.append(WindowOperation(timestamp: Date(), operation: .dismissOverlayReminder)) + onDismissOverlayReminder?() + } + + func dismissSubtleReminder() { + isSubtleReminderVisible = false + operations.append(WindowOperation(timestamp: Date(), operation: .dismissSubtleReminder)) + onDismissSubtleReminder?() + } + + func dismissAllReminders() { + isOverlayReminderVisible = false + isSubtleReminderVisible = false + operations.append(WindowOperation(timestamp: Date(), operation: .dismissAllReminders)) + onDismissOverlayReminder?() + onDismissSubtleReminder?() + } + + func showSettings(settingsManager: any SettingsProviding, initialTab: Int) { + operations.append(WindowOperation(timestamp: Date(), operation: .showSettings(initialTab: initialTab))) + onShowSettings?(initialTab) + } + + func showOnboarding(settingsManager: any SettingsProviding) { + operations.append(WindowOperation(timestamp: Date(), operation: .showOnboarding)) + onShowOnboarding?() + } + + // MARK: - Test Helpers + + /// Resets all state for a fresh test + func reset() { + isOverlayReminderVisible = false + isSubtleReminderVisible = false + operations.removeAll() + onShowOverlayReminder = nil + onShowSubtleReminder = nil + onDismissOverlayReminder = nil + onDismissSubtleReminder = nil + onShowSettings = nil + onShowOnboarding = nil + } + + /// Returns the number of times a specific operation was performed + func operationCount(_ operationType: WindowOperation.Operation) -> Int { + operations.filter { operation in + switch (operation.operation, operationType) { + case (.showOverlayReminder, .showOverlayReminder), + (.showSubtleReminder, .showSubtleReminder), + (.dismissOverlayReminder, .dismissOverlayReminder), + (.dismissSubtleReminder, .dismissSubtleReminder), + (.dismissAllReminders, .dismissAllReminders), + (.showOnboarding, .showOnboarding): + return true + case (.showSettings(let tab1), .showSettings(let tab2)): + return tab1 == tab2 + default: + return false + } + }.count + } + + /// Returns true if the operation was performed at least once + func didPerformOperation(_ operationType: WindowOperation.Operation) -> Bool { + operationCount(operationType) > 0 + } + + /// Returns the last operation performed, if any + var lastOperation: WindowOperation? { + operations.last + } +} + +// MARK: - Equatable Conformance for Testing + +extension MockWindowManager.WindowOperation.Operation: Equatable { + static func == (lhs: MockWindowManager.WindowOperation.Operation, rhs: MockWindowManager.WindowOperation.Operation) -> Bool { + switch (lhs, rhs) { + case (.showOverlayReminder, .showOverlayReminder), + (.showSubtleReminder, .showSubtleReminder), + (.dismissOverlayReminder, .dismissOverlayReminder), + (.dismissSubtleReminder, .dismissSubtleReminder), + (.dismissAllReminders, .dismissAllReminders), + (.showOnboarding, .showOnboarding): + return true + case (.showSettings(let tab1), .showSettings(let tab2)): + return tab1 == tab2 + default: + return false + } + } +} diff --git a/GazeTests/AppDelegateTestabilityTests.swift b/GazeTests/AppDelegateTestabilityTests.swift new file mode 100644 index 0000000..c6ab22c --- /dev/null +++ b/GazeTests/AppDelegateTestabilityTests.swift @@ -0,0 +1,97 @@ +// +// AppDelegateTestabilityTests.swift +// GazeTests +// +// Tests demonstrating AppDelegate testability with dependency injection. +// + +import XCTest +@testable import Gaze + +@MainActor +final class AppDelegateTestabilityTests: XCTestCase { + + var testEnv: TestEnvironment! + + override func setUp() async throws { + testEnv = TestEnvironment(settings: .onboardingCompleted) + } + + override func tearDown() async throws { + testEnv = nil + } + + func testAppDelegateCreationWithMocks() { + let appDelegate = testEnv.createAppDelegate() + + XCTAssertNotNil(appDelegate) + } + + func testWindowManagerReceivesReminderEvents() async throws { + let appDelegate = testEnv.createAppDelegate() + + // Simulate app launch + let notification = Notification(name: NSApplication.didFinishLaunchingNotification) + appDelegate.applicationDidFinishLaunching(notification) + + // Give time for setup + try await Task.sleep(nanoseconds: 100_000_000) // 100ms + + // Trigger a reminder through timer engine + if let timerEngine = appDelegate.timerEngine { + let timerId = TimerIdentifier.builtIn(.blink) + timerEngine.triggerReminder(for: timerId) + + // Give time for reminder to propagate + try await Task.sleep(nanoseconds: 100_000_000) // 100ms + + // Verify window manager received the show command + XCTAssertTrue(testEnv.windowManager.didPerformOperation(.showSubtleReminder)) + } else { + XCTFail("TimerEngine not initialized") + } + } + + func testSettingsChangesPropagate() async throws { + let appDelegate = testEnv.createAppDelegate() + + // Change a setting + testEnv.settingsManager.settings.lookAwayTimer.enabled = false + + // Give time for observation + try await Task.sleep(nanoseconds: 50_000_000) // 50ms + + // Verify the change propagated + XCTAssertFalse(testEnv.settingsManager.settings.lookAwayTimer.enabled) + } + + func testOpenSettingsUsesWindowManager() { + let appDelegate = testEnv.createAppDelegate() + + appDelegate.openSettings(tab: 2) + + // Give time for async dispatch + let expectation = XCTestExpectation(description: "Settings opened") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + XCTAssertTrue(self.testEnv.windowManager.didPerformOperation(.showSettings(initialTab: 2))) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } + + func testOpenOnboardingUsesWindowManager() { + let appDelegate = testEnv.createAppDelegate() + + appDelegate.openOnboarding() + + // Give time for async dispatch + let expectation = XCTestExpectation(description: "Onboarding opened") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + XCTAssertTrue(self.testEnv.windowManager.didPerformOperation(.showOnboarding)) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1.0) + } +} diff --git a/GazeTests/MockWindowManagerTests.swift b/GazeTests/MockWindowManagerTests.swift new file mode 100644 index 0000000..205c148 --- /dev/null +++ b/GazeTests/MockWindowManagerTests.swift @@ -0,0 +1,104 @@ +// +// MockWindowManagerTests.swift +// GazeTests +// +// Tests for MockWindowManager functionality. +// + +import SwiftUI +import XCTest +@testable import Gaze + +@MainActor +final class MockWindowManagerTests: XCTestCase { + + var windowManager: MockWindowManager! + + override func setUp() async throws { + windowManager = MockWindowManager() + } + + override func tearDown() async throws { + windowManager = nil + } + + func testShowOverlayReminder() { + XCTAssertFalse(windowManager.isOverlayReminderVisible) + + let view = Text("Test Overlay") + windowManager.showReminderWindow(view, windowType: .overlay) + + XCTAssertTrue(windowManager.isOverlayReminderVisible) + XCTAssertTrue(windowManager.didPerformOperation(.showOverlayReminder)) + } + + func testShowSubtleReminder() { + XCTAssertFalse(windowManager.isSubtleReminderVisible) + + let view = Text("Test Subtle") + windowManager.showReminderWindow(view, windowType: .subtle) + + XCTAssertTrue(windowManager.isSubtleReminderVisible) + XCTAssertTrue(windowManager.didPerformOperation(.showSubtleReminder)) + } + + func testDismissOverlayReminder() { + let view = Text("Test") + windowManager.showReminderWindow(view, windowType: .overlay) + XCTAssertTrue(windowManager.isOverlayReminderVisible) + + windowManager.dismissOverlayReminder() + + XCTAssertFalse(windowManager.isOverlayReminderVisible) + XCTAssertTrue(windowManager.didPerformOperation(.dismissOverlayReminder)) + } + + func testDismissAllReminders() { + let view = Text("Test") + windowManager.showReminderWindow(view, windowType: .overlay) + windowManager.showReminderWindow(view, windowType: .subtle) + + XCTAssertTrue(windowManager.isOverlayReminderVisible) + XCTAssertTrue(windowManager.isSubtleReminderVisible) + + windowManager.dismissAllReminders() + + XCTAssertFalse(windowManager.isOverlayReminderVisible) + XCTAssertFalse(windowManager.isSubtleReminderVisible) + } + + func testOperationTracking() { + let view = Text("Test") + + windowManager.showReminderWindow(view, windowType: .overlay) + windowManager.showReminderWindow(view, windowType: .overlay) + windowManager.dismissOverlayReminder() + + XCTAssertEqual(windowManager.operationCount(.showOverlayReminder), 2) + XCTAssertEqual(windowManager.operationCount(.dismissOverlayReminder), 1) + } + + func testCallbacks() { + var overlayShown = false + windowManager.onShowOverlayReminder = { + overlayShown = true + } + + let view = Text("Test") + windowManager.showReminderWindow(view, windowType: .overlay) + + XCTAssertTrue(overlayShown) + } + + func testReset() { + let view = Text("Test") + windowManager.showReminderWindow(view, windowType: .overlay) + windowManager.onShowOverlayReminder = { } + + windowManager.reset() + + XCTAssertFalse(windowManager.isOverlayReminderVisible) + XCTAssertEqual(windowManager.operations.count, 0) + XCTAssertNil(windowManager.onShowOverlayReminder) + } +} diff --git a/GazeTests/OnboardingNavigationTests.swift b/GazeTests/OnboardingNavigationTests.swift new file mode 100644 index 0000000..c4c07d2 --- /dev/null +++ b/GazeTests/OnboardingNavigationTests.swift @@ -0,0 +1,232 @@ +// +// OnboardingNavigationTests.swift +// GazeTests +// +// Comprehensive tests for onboarding flow navigation. +// + +import SwiftUI +import XCTest +@testable import Gaze + +@MainActor +final class OnboardingNavigationTests: XCTestCase { + + var testEnv: TestEnvironment! + + override func setUp() async throws { + var settings = AppSettings.defaults + settings.hasCompletedOnboarding = false + testEnv = TestEnvironment(settings: settings) + } + + override func tearDown() async throws { + testEnv = nil + } + + // MARK: - Navigation Tests + + func testOnboardingStartsAtWelcomePage() { + let onboarding = OnboardingContainerView(settingsManager: testEnv.settingsManager as! SettingsManager) + + // Verify initial state + XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding) + } + + func testNavigationForwardThroughAllPages() async throws { + var settings = testEnv.settingsManager.settings + + // Simulate moving through pages + let pages = [ + "Welcome", // 0 + "LookAway", // 1 + "Blink", // 2 + "Posture", // 3 + "General", // 4 + "Completion" // 5 + ] + + for (index, pageName) in pages.enumerated() { + // Verify we can track page progression + XCTAssertEqual(index, index, "Should be on page \(index): \(pageName)") + } + } + + func testNavigationBackward() { + // Start from page 3 (Posture) + var currentPage = 3 + + // Navigate backward + currentPage -= 1 + XCTAssertEqual(currentPage, 2, "Should navigate back to Blink page") + + currentPage -= 1 + XCTAssertEqual(currentPage, 1, "Should navigate back to LookAway page") + + currentPage -= 1 + XCTAssertEqual(currentPage, 0, "Should navigate back to Welcome page") + } + + func testCannotNavigateBackFromWelcome() { + let currentPage = 0 + + // Should not be able to go below 0 + XCTAssertEqual(currentPage, 0, "Should stay on Welcome page") + } + + func testSettingsPersistDuringNavigation() { + // Configure lookaway timer + var config = testEnv.settingsManager.settings.lookAwayTimer + config.enabled = true + config.intervalSeconds = 1200 + testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + + // Verify settings persisted + let retrieved = testEnv.settingsManager.timerConfiguration(for: .lookAway) + XCTAssertTrue(retrieved.enabled) + XCTAssertEqual(retrieved.intervalSeconds, 1200) + + // Configure blink timer + var blinkConfig = testEnv.settingsManager.settings.blinkTimer + blinkConfig.enabled = false + blinkConfig.intervalSeconds = 300 + testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) + + // Verify both settings persist + let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway) + let blink = testEnv.settingsManager.timerConfiguration(for: .blink) + + XCTAssertTrue(lookAway.enabled) + XCTAssertEqual(lookAway.intervalSeconds, 1200) + XCTAssertFalse(blink.enabled) + XCTAssertEqual(blink.intervalSeconds, 300) + } + + func testOnboardingCompletion() { + // Start with onboarding incomplete + XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding) + + // Complete onboarding + testEnv.settingsManager.settings.hasCompletedOnboarding = true + + // Verify completion + XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding) + } + + func testAllTimersConfiguredDuringOnboarding() { + // Configure all three built-in timers + var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer + lookAwayConfig.enabled = true + lookAwayConfig.intervalSeconds = 1200 + testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAwayConfig) + + var blinkConfig = testEnv.settingsManager.settings.blinkTimer + blinkConfig.enabled = true + blinkConfig.intervalSeconds = 300 + testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) + + var postureConfig = testEnv.settingsManager.settings.postureTimer + postureConfig.enabled = true + postureConfig.intervalSeconds = 1800 + testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: postureConfig) + + // Verify all configurations + let allConfigs = testEnv.settingsManager.allTimerConfigurations() + + XCTAssertEqual(allConfigs[.lookAway]?.intervalSeconds, 1200) + XCTAssertEqual(allConfigs[.blink]?.intervalSeconds, 300) + XCTAssertEqual(allConfigs[.posture]?.intervalSeconds, 1800) + + XCTAssertTrue(allConfigs[.lookAway]?.enabled ?? false) + XCTAssertTrue(allConfigs[.blink]?.enabled ?? false) + XCTAssertTrue(allConfigs[.posture]?.enabled ?? false) + } + + func testNavigationWithPartialConfiguration() { + // Configure only some timers + var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer + lookAwayConfig.enabled = true + testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAwayConfig) + + var blinkConfig = testEnv.settingsManager.settings.blinkTimer + blinkConfig.enabled = false + testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) + + // Should still be able to complete onboarding + testEnv.settingsManager.settings.hasCompletedOnboarding = true + XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding) + } + + func testGeneralSettingsConfigurationDuringOnboarding() { + // Configure general settings + testEnv.settingsManager.settings.playSounds = true + testEnv.settingsManager.settings.launchAtLogin = true + + XCTAssertTrue(testEnv.settingsManager.settings.playSounds) + XCTAssertTrue(testEnv.settingsManager.settings.launchAtLogin) + } + + func testOnboardingFlowFromStartToFinish() { + // Complete simulation of onboarding flow + XCTAssertFalse(testEnv.settingsManager.settings.hasCompletedOnboarding) + + // Page 0: Welcome - no configuration needed + + // Page 1: LookAway Setup + var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer + lookAwayConfig.enabled = true + lookAwayConfig.intervalSeconds = 1200 + testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAwayConfig) + + // Page 2: Blink Setup + var blinkConfig = testEnv.settingsManager.settings.blinkTimer + blinkConfig.enabled = true + blinkConfig.intervalSeconds = 300 + testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) + + // Page 3: Posture Setup + var postureConfig = testEnv.settingsManager.settings.postureTimer + postureConfig.enabled = false // User chooses to disable this one + testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: postureConfig) + + // Page 4: General Settings + testEnv.settingsManager.settings.playSounds = true + testEnv.settingsManager.settings.launchAtLogin = false + + // Page 5: Completion - mark as done + testEnv.settingsManager.settings.hasCompletedOnboarding = true + + // Verify final state + XCTAssertTrue(testEnv.settingsManager.settings.hasCompletedOnboarding) + + let finalConfigs = testEnv.settingsManager.allTimerConfigurations() + XCTAssertTrue(finalConfigs[.lookAway]?.enabled ?? false) + XCTAssertTrue(finalConfigs[.blink]?.enabled ?? false) + XCTAssertFalse(finalConfigs[.posture]?.enabled ?? true) + + XCTAssertTrue(testEnv.settingsManager.settings.playSounds) + XCTAssertFalse(testEnv.settingsManager.settings.launchAtLogin) + } + + func testNavigatingBackPreservesSettings() { + // Configure on page 1 + var lookAwayConfig = testEnv.settingsManager.settings.lookAwayTimer + lookAwayConfig.intervalSeconds = 1500 + testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAwayConfig) + + // Move forward to page 2 + var blinkConfig = testEnv.settingsManager.settings.blinkTimer + blinkConfig.intervalSeconds = 250 + testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: blinkConfig) + + // Navigate back to page 1 + // Verify lookaway settings still exist + let lookAway = testEnv.settingsManager.timerConfiguration(for: .lookAway) + XCTAssertEqual(lookAway.intervalSeconds, 1500) + + // Navigate forward again to page 2 + // Verify blink settings still exist + let blink = testEnv.settingsManager.timerConfiguration(for: .blink) + XCTAssertEqual(blink.intervalSeconds, 250) + } +} diff --git a/GazeTests/ServiceContainerTests.swift b/GazeTests/ServiceContainerTests.swift new file mode 100644 index 0000000..efe1345 --- /dev/null +++ b/GazeTests/ServiceContainerTests.swift @@ -0,0 +1,65 @@ +// +// ServiceContainerTests.swift +// GazeTests +// +// Tests for the dependency injection infrastructure. +// + +import XCTest +@testable import Gaze + +@MainActor +final class ServiceContainerTests: XCTestCase { + + func testProductionContainerCreation() { + let container = ServiceContainer.shared + + XCTAssertFalse(container.isTestEnvironment) + XCTAssertNotNil(container.settingsManager) + XCTAssertNotNil(container.enforceModeService) + } + + func testTestContainerCreation() { + let settings = AppSettings.onlyLookAwayEnabled + let container = ServiceContainer.forTesting(settings: settings) + + XCTAssertTrue(container.isTestEnvironment) + XCTAssertEqual(container.settingsManager.settings.lookAwayTimer.enabled, true) + XCTAssertEqual(container.settingsManager.settings.blinkTimer.enabled, false) + } + + func testTimerEngineCreation() { + let container = ServiceContainer.forTesting() + let timerEngine = container.timerEngine + + XCTAssertNotNil(timerEngine) + // Second access should return the same instance + XCTAssertTrue(container.timerEngine === timerEngine) + } + + func testCustomTimerEngineInjection() { + let container = ServiceContainer.forTesting() + let mockSettings = EnhancedMockSettingsManager(settings: .shortIntervals) + let customEngine = TimerEngine( + settingsManager: mockSettings, + timeProvider: MockTimeProvider() + ) + + container.setTimerEngine(customEngine) + XCTAssertTrue(container.timerEngine === customEngine) + } + + func testContainerReset() { + let container = ServiceContainer.forTesting() + + // Access timer engine to create it + _ = container.timerEngine + + // Reset should clear the timer engine + container.reset() + + // Accessing again should create a new instance + let newEngine = container.timerEngine + XCTAssertNotNil(newEngine) + } +} diff --git a/GazeTests/Services/EnforceModeServiceTests.swift b/GazeTests/Services/EnforceModeServiceTests.swift new file mode 100644 index 0000000..97816ff --- /dev/null +++ b/GazeTests/Services/EnforceModeServiceTests.swift @@ -0,0 +1,135 @@ +// +// EnforceModeServiceTests.swift +// GazeTests +// +// Unit tests for EnforceModeService. +// + +import XCTest +@testable import Gaze + +@MainActor +final class EnforceModeServiceTests: XCTestCase { + + var service: EnforceModeService! + + override func setUp() async throws { + service = EnforceModeService.shared + } + + override func tearDown() async throws { + service.disableEnforceMode() + service = nil + } + + // MARK: - Initialization Tests + + func testServiceInitialization() { + XCTAssertNotNil(service) + } + + func testInitialState() { + XCTAssertFalse(service.isEnforceModeEnabled) + XCTAssertFalse(service.isCameraActive) + XCTAssertFalse(service.userCompliedWithBreak) + } + + // MARK: - Enable/Disable Tests + + func testEnableEnforceMode() async { + await service.enableEnforceMode() + + // May or may not be enabled depending on camera permissions + // Just verify the method doesn't crash + XCTAssertNotNil(service) + } + + func testDisableEnforceMode() { + service.disableEnforceMode() + + XCTAssertFalse(service.isEnforceModeEnabled) + XCTAssertFalse(service.isCameraActive) + } + + func testEnableDisableCycle() async { + await service.enableEnforceMode() + service.disableEnforceMode() + + XCTAssertFalse(service.isEnforceModeEnabled) + } + + // MARK: - Timer Engine Integration Tests + + func testSetTimerEngine() { + let testEnv = TestEnvironment() + let timerEngine = testEnv.container.timerEngine + + service.setTimerEngine(timerEngine) + + // Should not crash + XCTAssertNotNil(service) + } + + // MARK: - Should Enforce Break Tests + + func testShouldEnforceBreakWhenDisabled() { + service.disableEnforceMode() + + let shouldEnforce = service.shouldEnforceBreak(for: .builtIn(.lookAway)) + XCTAssertFalse(shouldEnforce) + } + + // MARK: - Camera Tests + + func testStopCamera() { + service.stopCamera() + + XCTAssertFalse(service.isCameraActive) + } + + // MARK: - Compliance Tests + + func testCheckUserCompliance() { + service.checkUserCompliance() + + // Should not crash + XCTAssertNotNil(service) + } + + func testHandleReminderDismissed() { + service.handleReminderDismissed() + + // Should not crash + XCTAssertNotNil(service) + } + + // MARK: - Test Mode Tests + + func testStartTestMode() async { + await service.startTestMode() + + XCTAssertTrue(service.isTestMode) + } + + func testStopTestMode() { + service.stopTestMode() + + XCTAssertFalse(service.isTestMode) + } + + func testTestModeCycle() async { + await service.startTestMode() + XCTAssertTrue(service.isTestMode) + + service.stopTestMode() + XCTAssertFalse(service.isTestMode) + } + + // MARK: - Protocol Conformance Tests + + func testConformsToEnforceModeProviding() { + let providing: EnforceModeProviding = service + XCTAssertNotNil(providing) + XCTAssertFalse(providing.isEnforceModeEnabled) + } +} diff --git a/GazeTests/Services/FullscreenDetectionServiceTests.swift b/GazeTests/Services/FullscreenDetectionServiceTests.swift new file mode 100644 index 0000000..59e2cd9 --- /dev/null +++ b/GazeTests/Services/FullscreenDetectionServiceTests.swift @@ -0,0 +1,68 @@ +// +// FullscreenDetectionServiceTests.swift +// GazeTests +// +// Unit tests for FullscreenDetectionService. +// + +import Combine +import XCTest +@testable import Gaze + +@MainActor +final class FullscreenDetectionServiceTests: XCTestCase { + + var service: FullscreenDetectionService! + var cancellables: Set! + + override func setUp() async throws { + service = FullscreenDetectionService() + cancellables = [] + } + + override func tearDown() async throws { + cancellables = nil + service = nil + } + + // MARK: - Initialization Tests + + func testServiceInitialization() { + XCTAssertNotNil(service) + } + + func testInitialFullscreenState() { + // Initially should not be in fullscreen (unless actually in fullscreen) + XCTAssertNotNil(service.isFullscreenActive) + } + + // MARK: - Publisher Tests + + func testFullscreenStatePublisher() async throws { + let expectation = XCTestExpectation(description: "Fullscreen state published") + + service.$isFullscreenActive + .sink { isFullscreen in + expectation.fulfill() + } + .store(in: &cancellables) + + await fulfillment(of: [expectation], timeout: 0.1) + } + + // MARK: - Force Update Tests + + func testForceUpdate() { + // Should not crash + service.forceUpdate() + XCTAssertNotNil(service.isFullscreenActive) + } + + // MARK: - Protocol Conformance Tests + + func testConformsToFullscreenDetectionProviding() { + let providing: FullscreenDetectionProviding = service + XCTAssertNotNil(providing) + XCTAssertNotNil(providing.isFullscreenActive) + } +} diff --git a/GazeTests/Services/IdleMonitoringServiceTests.swift b/GazeTests/Services/IdleMonitoringServiceTests.swift new file mode 100644 index 0000000..87032ac --- /dev/null +++ b/GazeTests/Services/IdleMonitoringServiceTests.swift @@ -0,0 +1,89 @@ +// +// IdleMonitoringServiceTests.swift +// GazeTests +// +// Unit tests for IdleMonitoringService. +// + +import Combine +import XCTest +@testable import Gaze + +@MainActor +final class IdleMonitoringServiceTests: XCTestCase { + + var service: IdleMonitoringService! + var cancellables: Set! + + override func setUp() async throws { + service = IdleMonitoringService(idleThresholdMinutes: 5) + cancellables = [] + } + + override func tearDown() async throws { + cancellables = nil + service = nil + } + + // MARK: - Initialization Tests + + func testServiceInitialization() { + XCTAssertNotNil(service) + } + + func testInitialIdleState() { + // Initially should not be idle + XCTAssertFalse(service.isIdle) + } + + func testInitializationWithCustomThreshold() { + let customService = IdleMonitoringService(idleThresholdMinutes: 10) + XCTAssertNotNil(customService) + } + + // MARK: - Threshold Tests + + func testUpdateThreshold() { + service.updateThreshold(minutes: 15) + + // Should not crash + XCTAssertNotNil(service) + } + + func testUpdateThresholdMultipleTimes() { + service.updateThreshold(minutes: 5) + service.updateThreshold(minutes: 10) + service.updateThreshold(minutes: 3) + + XCTAssertNotNil(service) + } + + // MARK: - Publisher Tests + + func testIdleStatePublisher() async throws { + let expectation = XCTestExpectation(description: "Idle state published") + + service.$isIdle + .sink { isIdle in + expectation.fulfill() + } + .store(in: &cancellables) + + await fulfillment(of: [expectation], timeout: 0.1) + } + + // MARK: - Force Update Tests + + func testForceUpdate() { + service.forceUpdate() + XCTAssertNotNil(service.isIdle) + } + + // MARK: - Protocol Conformance Tests + + func testConformsToIdleMonitoringProviding() { + let providing: IdleMonitoringProviding = service + XCTAssertNotNil(providing) + XCTAssertNotNil(providing.isIdle) + } +} diff --git a/GazeTests/Services/LoggingManagerTests.swift b/GazeTests/Services/LoggingManagerTests.swift new file mode 100644 index 0000000..c044f3c --- /dev/null +++ b/GazeTests/Services/LoggingManagerTests.swift @@ -0,0 +1,61 @@ +// +// LoggingManagerTests.swift +// GazeTests +// +// Unit tests for LoggingManager. +// + +import os.log +import XCTest +@testable import Gaze + +final class LoggingManagerTests: XCTestCase { + + var loggingManager: LoggingManager! + + override func setUp() { + loggingManager = LoggingManager.shared + } + + override func tearDown() { + loggingManager = nil + } + + // MARK: - Initialization Tests + + func testLoggingManagerInitialization() { + XCTAssertNotNil(loggingManager) + } + + func testLoggersExist() { + XCTAssertNotNil(loggingManager.appLogger) + XCTAssertNotNil(loggingManager.timerLogger) + XCTAssertNotNil(loggingManager.systemLogger) + } + + // MARK: - Configuration Tests + + func testConfigureLogging() { + // Should not crash + loggingManager.configureLogging() + XCTAssertNotNil(loggingManager) + } + + // MARK: - Logger Usage Tests + + func testAppLoggerLogging() { + // Should not crash + loggingManager.appLogger.info("Test app log") + XCTAssertNotNil(loggingManager.appLogger) + } + + func testTimerLoggerLogging() { + loggingManager.timerLogger.info("Test timer log") + XCTAssertNotNil(loggingManager.timerLogger) + } + + func testSystemLoggerLogging() { + loggingManager.systemLogger.info("Test system log") + XCTAssertNotNil(loggingManager.systemLogger) + } +} diff --git a/GazeTests/Services/SettingsManagerTests.swift b/GazeTests/Services/SettingsManagerTests.swift new file mode 100644 index 0000000..9f05320 --- /dev/null +++ b/GazeTests/Services/SettingsManagerTests.swift @@ -0,0 +1,187 @@ +// +// SettingsManagerTests.swift +// GazeTests +// +// Unit tests for SettingsManager service. +// + +import Combine +import XCTest +@testable import Gaze + +@MainActor +final class SettingsManagerTests: XCTestCase { + + var settingsManager: SettingsManager! + var cancellables: Set! + + override func setUp() async throws { + settingsManager = SettingsManager.shared + cancellables = [] + + // Reset to defaults for testing + settingsManager.resetToDefaults() + } + + override func tearDown() async throws { + cancellables = nil + settingsManager = nil + } + + // MARK: - Initialization Tests + + func testSettingsManagerInitialization() { + XCTAssertNotNil(settingsManager) + XCTAssertNotNil(settingsManager.settings) + } + + func testDefaultSettingsValues() { + let defaults = AppSettings.defaults + + XCTAssertTrue(defaults.lookAwayTimer.enabled) + XCTAssertTrue(defaults.blinkTimer.enabled) + XCTAssertTrue(defaults.postureTimer.enabled) + XCTAssertFalse(defaults.hasCompletedOnboarding) + } + + // MARK: - Timer Configuration Tests + + func testGetTimerConfiguration() { + let lookAwayConfig = settingsManager.timerConfiguration(for: .lookAway) + XCTAssertNotNil(lookAwayConfig) + XCTAssertTrue(lookAwayConfig.enabled) + } + + func testUpdateTimerConfiguration() { + var config = settingsManager.timerConfiguration(for: .lookAway) + config.intervalSeconds = 1500 + config.enabled = false + + settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + + let updated = settingsManager.timerConfiguration(for: .lookAway) + XCTAssertEqual(updated.intervalSeconds, 1500) + XCTAssertFalse(updated.enabled) + } + + func testAllTimerConfigurations() { + let allConfigs = settingsManager.allTimerConfigurations() + + XCTAssertEqual(allConfigs.count, 3) + XCTAssertNotNil(allConfigs[.lookAway]) + XCTAssertNotNil(allConfigs[.blink]) + XCTAssertNotNil(allConfigs[.posture]) + } + + func testUpdateMultipleTimerConfigurations() { + var lookAway = settingsManager.timerConfiguration(for: .lookAway) + lookAway.intervalSeconds = 1000 + settingsManager.updateTimerConfiguration(for: .lookAway, configuration: lookAway) + + var blink = settingsManager.timerConfiguration(for: .blink) + blink.intervalSeconds = 250 + settingsManager.updateTimerConfiguration(for: .blink, configuration: blink) + + XCTAssertEqual(settingsManager.timerConfiguration(for: .lookAway).intervalSeconds, 1000) + XCTAssertEqual(settingsManager.timerConfiguration(for: .blink).intervalSeconds, 250) + } + + // MARK: - Settings Publisher Tests + + func testSettingsPublisherEmitsChanges() async throws { + let expectation = XCTestExpectation(description: "Settings changed") + var receivedSettings: AppSettings? + + settingsManager.$settings + .dropFirst() // Skip initial value + .sink { settings in + receivedSettings = settings + expectation.fulfill() + } + .store(in: &cancellables) + + // Trigger change + settingsManager.settings.playSounds = !settingsManager.settings.playSounds + + await fulfillment(of: [expectation], timeout: 1.0) + XCTAssertNotNil(receivedSettings) + } + + // MARK: - Save/Load Tests + + func testSave() { + settingsManager.settings.playSounds = false + settingsManager.save() + + // Save is debounced, so just verify it doesn't crash + XCTAssertFalse(settingsManager.settings.playSounds) + } + + func testSaveImmediately() { + settingsManager.settings.launchAtLogin = true + settingsManager.saveImmediately() + + // Verify the setting persisted + XCTAssertTrue(settingsManager.settings.launchAtLogin) + } + + func testLoad() { + // Load should restore settings from UserDefaults + settingsManager.load() + XCTAssertNotNil(settingsManager.settings) + } + + // MARK: - Reset Tests + + func testResetToDefaults() { + // Modify settings + settingsManager.settings.playSounds = false + settingsManager.settings.launchAtLogin = true + var config = settingsManager.timerConfiguration(for: .lookAway) + config.intervalSeconds = 5000 + settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + + // Reset + settingsManager.resetToDefaults() + + // Verify reset to defaults + let defaults = AppSettings.defaults + XCTAssertEqual(settingsManager.settings.playSounds, defaults.playSounds) + XCTAssertEqual(settingsManager.settings.launchAtLogin, defaults.launchAtLogin) + } + + // MARK: - Onboarding Tests + + func testOnboardingCompletion() { + XCTAssertFalse(settingsManager.settings.hasCompletedOnboarding) + + settingsManager.settings.hasCompletedOnboarding = true + XCTAssertTrue(settingsManager.settings.hasCompletedOnboarding) + } + + // MARK: - General Settings Tests + + func testPlaySoundsToggle() { + let initial = settingsManager.settings.playSounds + settingsManager.settings.playSounds = !initial + XCTAssertNotEqual(settingsManager.settings.playSounds, initial) + } + + func testLaunchAtLoginToggle() { + let initial = settingsManager.settings.launchAtLogin + settingsManager.settings.launchAtLogin = !initial + XCTAssertNotEqual(settingsManager.settings.launchAtLogin, initial) + } + + // MARK: - Smart Mode Settings Tests + + func testSmartModeSettings() { + settingsManager.settings.smartMode.autoPauseOnFullscreen = true + settingsManager.settings.smartMode.autoPauseOnIdle = true + settingsManager.settings.smartMode.idleThresholdMinutes = 10 + + XCTAssertTrue(settingsManager.settings.smartMode.autoPauseOnFullscreen) + XCTAssertTrue(settingsManager.settings.smartMode.autoPauseOnIdle) + XCTAssertEqual(settingsManager.settings.smartMode.idleThresholdMinutes, 10) + } +} diff --git a/GazeTests/Services/TimerEngineTests.swift b/GazeTests/Services/TimerEngineTests.swift new file mode 100644 index 0000000..e4a71d5 --- /dev/null +++ b/GazeTests/Services/TimerEngineTests.swift @@ -0,0 +1,302 @@ +// +// TimerEngineTests.swift +// GazeTests +// +// Unit tests for TimerEngine service. +// + +import Combine +import XCTest +@testable import Gaze + +@MainActor +final class TimerEngineTests: XCTestCase { + + var testEnv: TestEnvironment! + var timerEngine: TimerEngine! + var cancellables: Set! + + override func setUp() async throws { + testEnv = TestEnvironment(settings: .defaults) + timerEngine = testEnv.container.timerEngine + cancellables = [] + } + + override func tearDown() async throws { + timerEngine?.stop() + cancellables = nil + timerEngine = nil + testEnv = nil + } + + // MARK: - Initialization Tests + + func testTimerEngineInitialization() { + XCTAssertNotNil(timerEngine) + XCTAssertEqual(timerEngine.timerStates.count, 0) + XCTAssertNil(timerEngine.activeReminder) + } + + func testTimerEngineWithCustomTimeProvider() { + let timeProvider = MockTimeProvider() + let engine = TimerEngine( + settingsManager: testEnv.settingsManager, + timeProvider: timeProvider + ) + + XCTAssertNotNil(engine) + } + + // MARK: - Start/Stop Tests + + func testStartTimers() { + timerEngine.start() + + // Should create timer states for enabled timers + XCTAssertGreaterThan(timerEngine.timerStates.count, 0) + } + + func testStopTimers() { + timerEngine.start() + let initialCount = timerEngine.timerStates.count + XCTAssertGreaterThan(initialCount, 0) + + timerEngine.stop() + + // Timers should be cleared + XCTAssertEqual(timerEngine.timerStates.count, 0) + } + + func testRestartTimers() { + timerEngine.start() + let firstCount = timerEngine.timerStates.count + + timerEngine.stop() + XCTAssertEqual(timerEngine.timerStates.count, 0) + + timerEngine.start() + let secondCount = timerEngine.timerStates.count + + XCTAssertEqual(firstCount, secondCount) + } + + // MARK: - Pause/Resume Tests + + func testPauseAllTimers() { + timerEngine.start() + timerEngine.pause() + + for (_, state) in timerEngine.timerStates { + XCTAssertTrue(state.isPaused) + } + } + + func testResumeAllTimers() { + timerEngine.start() + timerEngine.pause() + timerEngine.resume() + + for (_, state) in timerEngine.timerStates { + XCTAssertFalse(state.isPaused) + } + } + + func testPauseSpecificTimer() { + timerEngine.start() + + guard let firstTimer = timerEngine.timerStates.keys.first else { + XCTFail("No timers available") + return + } + + timerEngine.pauseTimer(identifier: firstTimer) + + let state = timerEngine.timerStates[firstTimer] + XCTAssertTrue(state?.isPaused ?? false) + } + + func testResumeSpecificTimer() { + timerEngine.start() + + guard let firstTimer = timerEngine.timerStates.keys.first else { + XCTFail("No timers available") + return + } + + timerEngine.pauseTimer(identifier: firstTimer) + XCTAssertTrue(timerEngine.isTimerPaused(firstTimer)) + + timerEngine.resumeTimer(identifier: firstTimer) + XCTAssertFalse(timerEngine.isTimerPaused(firstTimer)) + } + + // MARK: - Skip Tests + + func testSkipNext() { + timerEngine.start() + + guard let firstTimer = timerEngine.timerStates.keys.first else { + XCTFail("No timers available") + return + } + + timerEngine.skipNext(identifier: firstTimer) + + // Timer should be reset + XCTAssertNotNil(timerEngine.timerStates[firstTimer]) + } + + // MARK: - Reminder Tests + + func testTriggerReminder() async throws { + timerEngine.start() + + guard let firstTimer = timerEngine.timerStates.keys.first else { + XCTFail("No timers available") + return + } + + timerEngine.triggerReminder(for: firstTimer) + + // Give time for async operations + try await Task.sleep(nanoseconds: 50_000_000) + + XCTAssertNotNil(timerEngine.activeReminder) + } + + func testDismissReminder() { + timerEngine.start() + + guard let firstTimer = timerEngine.timerStates.keys.first else { + XCTFail("No timers available") + return + } + + timerEngine.triggerReminder(for: firstTimer) + XCTAssertNotNil(timerEngine.activeReminder) + + timerEngine.dismissReminder() + XCTAssertNil(timerEngine.activeReminder) + } + + // MARK: - Time Remaining Tests + + func testGetTimeRemaining() { + timerEngine.start() + + guard let firstTimer = timerEngine.timerStates.keys.first else { + XCTFail("No timers available") + return + } + + let remaining = timerEngine.getTimeRemaining(for: firstTimer) + XCTAssertGreaterThan(remaining, 0) + } + + func testGetFormattedTimeRemaining() { + timerEngine.start() + + guard let firstTimer = timerEngine.timerStates.keys.first else { + XCTFail("No timers available") + return + } + + let formatted = timerEngine.getFormattedTimeRemaining(for: firstTimer) + XCTAssertFalse(formatted.isEmpty) + XCTAssertTrue(formatted.contains(":")) + } + + // MARK: - Timer State Publisher Tests + + func testTimerStatesPublisher() async throws { + let expectation = XCTestExpectation(description: "Timer states changed") + + timerEngine.$timerStates + .dropFirst() + .sink { states in + if !states.isEmpty { + expectation.fulfill() + } + } + .store(in: &cancellables) + + timerEngine.start() + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testActiveReminderPublisher() async throws { + let expectation = XCTestExpectation(description: "Active reminder changed") + + timerEngine.$activeReminder + .dropFirst() + .sink { reminder in + if reminder != nil { + expectation.fulfill() + } + } + .store(in: &cancellables) + + timerEngine.start() + + guard let firstTimer = timerEngine.timerStates.keys.first else { + XCTFail("No timers available") + return + } + + timerEngine.triggerReminder(for: firstTimer) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + // MARK: - System Sleep/Wake Tests + + func testHandleSystemSleep() { + timerEngine.start() + let statesBefore = timerEngine.timerStates.count + + timerEngine.handleSystemSleep() + + // States should still exist + XCTAssertEqual(timerEngine.timerStates.count, statesBefore) + } + + func testHandleSystemWake() { + timerEngine.start() + timerEngine.handleSystemSleep() + timerEngine.handleSystemWake() + + // Should handle wake event without crashing + XCTAssertGreaterThan(timerEngine.timerStates.count, 0) + } + + // MARK: - Disabled Timer Tests + + func testDisabledTimersNotInitialized() { + var settings = AppSettings.defaults + settings.lookAwayTimer.enabled = false + settings.blinkTimer.enabled = false + settings.postureTimer.enabled = false + + let settingsManager = EnhancedMockSettingsManager(settings: settings) + let engine = TimerEngine(settingsManager: settingsManager) + + engine.start() + + XCTAssertEqual(engine.timerStates.count, 0) + } + + func testPartiallyEnabledTimers() { + var settings = AppSettings.defaults + settings.lookAwayTimer.enabled = true + settings.blinkTimer.enabled = false + settings.postureTimer.enabled = false + + let settingsManager = EnhancedMockSettingsManager(settings: settings) + let engine = TimerEngine(settingsManager: settingsManager) + + engine.start() + + XCTAssertEqual(engine.timerStates.count, 1) + } +} diff --git a/GazeTests/Services/UsageTrackingServiceTests.swift b/GazeTests/Services/UsageTrackingServiceTests.swift new file mode 100644 index 0000000..e565f5a --- /dev/null +++ b/GazeTests/Services/UsageTrackingServiceTests.swift @@ -0,0 +1,62 @@ +// +// UsageTrackingServiceTests.swift +// GazeTests +// +// Unit tests for UsageTrackingService. +// + +import XCTest +@testable import Gaze + +@MainActor +final class UsageTrackingServiceTests: XCTestCase { + + var service: UsageTrackingService! + + override func setUp() async throws { + service = UsageTrackingService(resetThresholdMinutes: 60) + } + + override func tearDown() async throws { + service = nil + } + + // MARK: - Initialization Tests + + func testServiceInitialization() { + XCTAssertNotNil(service) + } + + func testInitializationWithCustomThreshold() { + let customService = UsageTrackingService(resetThresholdMinutes: 120) + XCTAssertNotNil(customService) + } + + // MARK: - Threshold Tests + + func testUpdateResetThreshold() { + service.updateResetThreshold(minutes: 90) + + // Should not crash + XCTAssertNotNil(service) + } + + func testUpdateThresholdMultipleTimes() { + service.updateResetThreshold(minutes: 30) + service.updateResetThreshold(minutes: 60) + service.updateResetThreshold(minutes: 120) + + XCTAssertNotNil(service) + } + + // MARK: - Idle Monitoring Integration Tests + + func testSetupIdleMonitoring() { + let idleService = IdleMonitoringService(idleThresholdMinutes: 5) + + service.setupIdleMonitoring(idleService) + + // Should not crash + XCTAssertNotNil(service) + } +} diff --git a/GazeTests/Services/WindowManagerTests.swift b/GazeTests/Services/WindowManagerTests.swift new file mode 100644 index 0000000..5195802 --- /dev/null +++ b/GazeTests/Services/WindowManagerTests.swift @@ -0,0 +1,129 @@ +// +// WindowManagerTests.swift +// GazeTests +// +// Unit tests for WindowManager service. +// + +import SwiftUI +import XCTest +@testable import Gaze + +@MainActor +final class WindowManagerTests: XCTestCase { + + var windowManager: WindowManager! + + override func setUp() async throws { + windowManager = WindowManager.shared + } + + override func tearDown() async throws { + windowManager.dismissAllReminders() + windowManager = nil + } + + // MARK: - Initialization Tests + + func testWindowManagerInitialization() { + XCTAssertNotNil(windowManager) + } + + func testInitialState() { + XCTAssertFalse(windowManager.isOverlayReminderVisible) + XCTAssertFalse(windowManager.isSubtleReminderVisible) + } + + // MARK: - Window Visibility Tests + + func testOverlayReminderVisibility() { + XCTAssertFalse(windowManager.isOverlayReminderVisible) + + let view = Text("Test Overlay") + windowManager.showReminderWindow(view, windowType: .overlay) + + XCTAssertTrue(windowManager.isOverlayReminderVisible) + + windowManager.dismissOverlayReminder() + XCTAssertFalse(windowManager.isOverlayReminderVisible) + } + + func testSubtleReminderVisibility() { + XCTAssertFalse(windowManager.isSubtleReminderVisible) + + let view = Text("Test Subtle") + windowManager.showReminderWindow(view, windowType: .subtle) + + XCTAssertTrue(windowManager.isSubtleReminderVisible) + + windowManager.dismissSubtleReminder() + XCTAssertFalse(windowManager.isSubtleReminderVisible) + } + + // MARK: - Multiple Window Tests + + func testShowBothWindowTypes() { + let overlayView = Text("Overlay") + let subtleView = Text("Subtle") + + windowManager.showReminderWindow(overlayView, windowType: .overlay) + windowManager.showReminderWindow(subtleView, windowType: .subtle) + + XCTAssertTrue(windowManager.isOverlayReminderVisible) + XCTAssertTrue(windowManager.isSubtleReminderVisible) + } + + func testDismissAllReminders() { + let overlayView = Text("Overlay") + let subtleView = Text("Subtle") + + windowManager.showReminderWindow(overlayView, windowType: .overlay) + windowManager.showReminderWindow(subtleView, windowType: .subtle) + + windowManager.dismissAllReminders() + + XCTAssertFalse(windowManager.isOverlayReminderVisible) + XCTAssertFalse(windowManager.isSubtleReminderVisible) + } + + // MARK: - Window Replacement Tests + + func testReplaceOverlayWindow() { + let firstView = Text("First Overlay") + let secondView = Text("Second Overlay") + + windowManager.showReminderWindow(firstView, windowType: .overlay) + XCTAssertTrue(windowManager.isOverlayReminderVisible) + + // Showing a new overlay should replace the old one + windowManager.showReminderWindow(secondView, windowType: .overlay) + XCTAssertTrue(windowManager.isOverlayReminderVisible) + } + + func testReplaceSubtleWindow() { + let firstView = Text("First Subtle") + let secondView = Text("Second Subtle") + + windowManager.showReminderWindow(firstView, windowType: .subtle) + XCTAssertTrue(windowManager.isSubtleReminderVisible) + + windowManager.showReminderWindow(secondView, windowType: .subtle) + XCTAssertTrue(windowManager.isSubtleReminderVisible) + } + + // MARK: - Integration with Settings Tests + + func testShowSettingsWithSettingsManager() { + let settingsManager = SettingsManager.shared + + // Should not crash + windowManager.showSettings(settingsManager: settingsManager, initialTab: 0) + } + + func testShowOnboardingWithSettingsManager() { + let settingsManager = SettingsManager.shared + + // Should not crash + windowManager.showOnboarding(settingsManager: settingsManager) + } +} diff --git a/GazeTests/TestHelpers.swift b/GazeTests/TestHelpers.swift new file mode 100644 index 0000000..8cf45e6 --- /dev/null +++ b/GazeTests/TestHelpers.swift @@ -0,0 +1,259 @@ +// +// TestHelpers.swift +// GazeTests +// +// Test helpers and utilities for unit testing. +// + +import Foundation +import XCTest +@testable import Gaze + +// MARK: - Enhanced MockSettingsManager + +/// Enhanced mock settings manager with full control over state +@MainActor +final class EnhancedMockSettingsManager: ObservableObject, SettingsProviding { + @Published var settings: AppSettings + + var settingsPublisher: Published.Publisher { + $settings + } + + private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ + .lookAway: \.lookAwayTimer, + .blink: \.blinkTimer, + .posture: \.postureTimer, + ] + + // Track method calls for verification + private(set) var saveCallCount = 0 + private(set) var saveImmediatelyCallCount = 0 + private(set) var loadCallCount = 0 + private(set) var resetToDefaultsCallCount = 0 + + init(settings: AppSettings = .defaults) { + self.settings = settings + } + + func timerConfiguration(for type: TimerType) -> TimerConfiguration { + guard let keyPath = timerConfigKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + return settings[keyPath: keyPath] + } + + func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { + guard let keyPath = timerConfigKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + settings[keyPath: keyPath] = configuration + } + + func allTimerConfigurations() -> [TimerType: TimerConfiguration] { + var configs: [TimerType: TimerConfiguration] = [:] + for (type, keyPath) in timerConfigKeyPaths { + configs[type] = settings[keyPath: keyPath] + } + return configs + } + + func save() { + saveCallCount += 1 + } + + func saveImmediately() { + saveImmediatelyCallCount += 1 + } + + func load() { + loadCallCount += 1 + } + + func resetToDefaults() { + resetToDefaultsCallCount += 1 + settings = .defaults + } + + // Test helpers + func reset() { + saveCallCount = 0 + saveImmediatelyCallCount = 0 + loadCallCount = 0 + resetToDefaultsCallCount = 0 + settings = .defaults + } +} + +// MARK: - Mock Smart Mode Services + +@MainActor +final class MockFullscreenDetectionService: ObservableObject, FullscreenDetectionProviding { + @Published var isFullscreenActive: Bool = false + + var isFullscreenActivePublisher: Published.Publisher { + $isFullscreenActive + } + + private(set) var forceUpdateCallCount = 0 + + func forceUpdate() { + forceUpdateCallCount += 1 + } + + func simulateFullscreen(_ active: Bool) { + isFullscreenActive = active + } +} + +@MainActor +final class MockIdleMonitoringService: ObservableObject, IdleMonitoringProviding { + @Published var isIdle: Bool = false + + var isIdlePublisher: Published.Publisher { + $isIdle + } + + private(set) var thresholdMinutes: Int = 5 + private(set) var forceUpdateCallCount = 0 + + func updateThreshold(minutes: Int) { + thresholdMinutes = minutes + } + + func forceUpdate() { + forceUpdateCallCount += 1 + } + + func simulateIdle(_ idle: Bool) { + isIdle = idle + } +} + +// MARK: - Test Fixtures + +extension AppSettings { + /// Settings with all timers disabled + static var allTimersDisabled: AppSettings { + var settings = AppSettings.defaults + settings.lookAwayTimer.enabled = false + settings.blinkTimer.enabled = false + settings.postureTimer.enabled = false + return settings + } + + /// Settings with only lookAway timer enabled + static var onlyLookAwayEnabled: AppSettings { + var settings = AppSettings.defaults + settings.lookAwayTimer.enabled = true + settings.blinkTimer.enabled = false + settings.postureTimer.enabled = false + return settings + } + + /// Settings with short intervals for testing + static var shortIntervals: AppSettings { + var settings = AppSettings.defaults + settings.lookAwayTimer.intervalSeconds = 5 + settings.blinkTimer.intervalSeconds = 3 + settings.postureTimer.intervalSeconds = 7 + return settings + } + + /// Settings with onboarding completed + static var onboardingCompleted: AppSettings { + var settings = AppSettings.defaults + settings.hasCompletedOnboarding = true + return settings + } + + /// Settings with smart mode fully enabled + static var smartModeEnabled: AppSettings { + var settings = AppSettings.defaults + settings.smartMode.autoPauseOnFullscreen = true + settings.smartMode.autoPauseOnIdle = true + settings.smartMode.idleThresholdMinutes = 5 + return settings + } +} + +// MARK: - Test Utilities + +/// Creates a service container configured for testing +@MainActor +func createTestContainer( + settings: AppSettings = .defaults +) -> ServiceContainer { + return ServiceContainer.forTesting(settings: settings) +} + +/// Creates a complete test environment with all mocks +@MainActor +struct TestEnvironment { + let container: ServiceContainer + let windowManager: MockWindowManager + let settingsManager: EnhancedMockSettingsManager + let timeProvider: MockTimeProvider + + init(settings: AppSettings = .defaults) { + self.settingsManager = EnhancedMockSettingsManager(settings: settings) + self.container = ServiceContainer(settingsManager: settingsManager) + self.windowManager = MockWindowManager() + self.timeProvider = MockTimeProvider() + } + + /// Creates an AppDelegate with all test dependencies + func createAppDelegate() -> AppDelegate { + return AppDelegate(serviceContainer: container, windowManager: windowManager) + } + + /// Resets all mock state + func reset() { + windowManager.reset() + settingsManager.reset() + } +} + +// MARK: - XCTest Extensions + +extension XCTestCase { + /// Waits for a condition to be true with timeout + @MainActor + func waitFor( + _ condition: @escaping () -> Bool, + timeout: TimeInterval = 1.0, + message: String = "Condition not met" + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while !condition() { + if Date() > deadline { + XCTFail(message) + return + } + try await Task.sleep(nanoseconds: 10_000_000) // 10ms + } + } + + /// Waits for a published value to change + @MainActor + func waitForPublisher( + _ publisher: Published.Publisher, + toEqual expectedValue: T, + timeout: TimeInterval = 1.0 + ) async throws { + let expectation = XCTestExpectation(description: "Publisher value changed") + var cancellable: AnyCancellable? + + cancellable = publisher.sink { value in + if value == expectedValue { + expectation.fulfill() + } + } + + await fulfillment(of: [expectation], timeout: timeout) + cancellable?.cancel() + } +} + +// MARK: - Import Statement for Combine +import Combine diff --git a/GazeTests/TimerEngineTestabilityTests.swift b/GazeTests/TimerEngineTestabilityTests.swift new file mode 100644 index 0000000..16da5b6 --- /dev/null +++ b/GazeTests/TimerEngineTestabilityTests.swift @@ -0,0 +1,112 @@ +// +// TimerEngineTestabilityTests.swift +// GazeTests +// +// Tests demonstrating TimerEngine testability with dependency injection. +// + +import Combine +import XCTest +@testable import Gaze + +@MainActor +final class TimerEngineTestabilityTests: XCTestCase { + + var testEnv: TestEnvironment! + var cancellables: Set! + + override func setUp() async throws { + testEnv = TestEnvironment(settings: .shortIntervals) + cancellables = [] + } + + override func tearDown() async throws { + cancellables = nil + testEnv = nil + } + + func testTimerEngineCreationWithMocks() { + let timeProvider = MockTimeProvider() + let timerEngine = TimerEngine( + settingsManager: testEnv.settingsManager, + timeProvider: timeProvider + ) + + XCTAssertNotNil(timerEngine) + XCTAssertEqual(timerEngine.timerStates.count, 0) + } + + func testTimerEngineUsesInjectedSettings() { + var settings = AppSettings.defaults + settings.lookAwayTimer.enabled = true + settings.blinkTimer.enabled = false + settings.postureTimer.enabled = false + + testEnv.settingsManager.settings = settings + let timerEngine = testEnv.container.timerEngine + + timerEngine.start() + + // Only lookAway should be active + let lookAwayTimer = timerEngine.timerStates.first { $0.key == .builtIn(.lookAway) } + let blinkTimer = timerEngine.timerStates.first { $0.key == .builtIn(.blink) } + + XCTAssertNotNil(lookAwayTimer) + XCTAssertNil(blinkTimer) + } + + func testTimerEngineWithMockTimeProvider() { + let timeProvider = MockTimeProvider(startTime: Date()) + let timerEngine = TimerEngine( + settingsManager: testEnv.settingsManager, + timeProvider: timeProvider + ) + + // Start timers + timerEngine.start() + + // Advance time + timeProvider.advance(by: 10) + + // Timer engine should use the mocked time + XCTAssertNotNil(timerEngine.timerStates) + } + + func testPauseAndResumeWithMocks() { + let timerEngine = testEnv.container.timerEngine + timerEngine.start() + + timerEngine.pause() + + // Verify all timers are paused + for (_, state) in timerEngine.timerStates { + XCTAssertTrue(state.isPaused) + } + + timerEngine.resume() + + // Verify all timers are resumed + for (_, state) in timerEngine.timerStates { + XCTAssertFalse(state.isPaused) + } + } + + func testReminderEventPublishing() async throws { + let timerEngine = testEnv.container.timerEngine + + var receivedReminder: ReminderEvent? + timerEngine.$activeReminder + .sink { reminder in + receivedReminder = reminder + } + .store(in: &cancellables) + + let timerId = TimerIdentifier.builtIn(.lookAway) + timerEngine.triggerReminder(for: timerId) + + // Give time for publisher to fire + try await Task.sleep(nanoseconds: 10_000_000) + + XCTAssertNotNil(receivedReminder) + } +} diff --git a/GazeTests/Views/BlinkSetupViewTests.swift b/GazeTests/Views/BlinkSetupViewTests.swift new file mode 100644 index 0000000..eb7730a --- /dev/null +++ b/GazeTests/Views/BlinkSetupViewTests.swift @@ -0,0 +1,76 @@ +// +// BlinkSetupViewTests.swift +// GazeTests +// +// Tests for BlinkSetupView component. +// + +import SwiftUI +import XCTest +@testable import Gaze + +@MainActor +final class BlinkSetupViewTests: XCTestCase { + + var testEnv: TestEnvironment! + + override func setUp() async throws { + testEnv = TestEnvironment() + } + + override func tearDown() async throws { + testEnv = nil + } + + func testBlinkSetupInitialization() { + let view = BlinkSetupView( + settingsManager: testEnv.settingsManager as! SettingsManager + ) + XCTAssertNotNil(view) + } + + func testBlinkTimerConfigurationChanges() { + let initial = testEnv.settingsManager.timerConfiguration(for: .blink) + + var modified = initial + modified.enabled = true + modified.intervalSeconds = 300 + testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: modified) + + let updated = testEnv.settingsManager.timerConfiguration(for: .blink) + XCTAssertTrue(updated.enabled) + XCTAssertEqual(updated.intervalSeconds, 300) + } + + func testBlinkTimerEnableDisable() { + var config = testEnv.settingsManager.timerConfiguration(for: .blink) + + config.enabled = true + testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: config) + XCTAssertTrue(testEnv.settingsManager.timerConfiguration(for: .blink).enabled) + + config.enabled = false + testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: config) + XCTAssertFalse(testEnv.settingsManager.timerConfiguration(for: .blink).enabled) + } + + func testBlinkIntervalValidation() { + var config = testEnv.settingsManager.timerConfiguration(for: .blink) + + let intervals = [180, 240, 300, 360, 600] + for interval in intervals { + config.intervalSeconds = interval + testEnv.settingsManager.updateTimerConfiguration(for: .blink, configuration: config) + + let retrieved = testEnv.settingsManager.timerConfiguration(for: .blink) + XCTAssertEqual(retrieved.intervalSeconds, interval) + } + } + + func testBlinkAccessibilityIdentifier() { + XCTAssertEqual( + AccessibilityIdentifiers.Onboarding.blinkPage, + "onboarding.page.blink" + ) + } +} diff --git a/GazeTests/Views/CompletionViewTests.swift b/GazeTests/Views/CompletionViewTests.swift new file mode 100644 index 0000000..34557ac --- /dev/null +++ b/GazeTests/Views/CompletionViewTests.swift @@ -0,0 +1,26 @@ +// +// CompletionViewTests.swift +// GazeTests +// +// Tests for CompletionView component. +// + +import SwiftUI +import XCTest +@testable import Gaze + +@MainActor +final class CompletionViewTests: XCTestCase { + + func testCompletionViewInitialization() { + let view = CompletionView() + XCTAssertNotNil(view) + } + + func testCompletionAccessibilityIdentifier() { + XCTAssertEqual( + AccessibilityIdentifiers.Onboarding.completionPage, + "onboarding.page.completion" + ) + } +} diff --git a/GazeTests/Views/GeneralSetupViewTests.swift b/GazeTests/Views/GeneralSetupViewTests.swift new file mode 100644 index 0000000..f63ca17 --- /dev/null +++ b/GazeTests/Views/GeneralSetupViewTests.swift @@ -0,0 +1,74 @@ +// +// GeneralSetupViewTests.swift +// GazeTests +// +// Tests for GeneralSetupView component. +// + +import SwiftUI +import XCTest +@testable import Gaze + +@MainActor +final class GeneralSetupViewTests: XCTestCase { + + var testEnv: TestEnvironment! + + override func setUp() async throws { + testEnv = TestEnvironment() + } + + override func tearDown() async throws { + testEnv = nil + } + + func testGeneralSetupInitialization() { + let view = GeneralSetupView( + settingsManager: testEnv.settingsManager as! SettingsManager, + isOnboarding: true + ) + XCTAssertNotNil(view) + } + + func testPlaySoundsToggle() { + // Initial state + let initial = testEnv.settingsManager.settings.playSounds + + // Toggle on + testEnv.settingsManager.settings.playSounds = true + XCTAssertTrue(testEnv.settingsManager.settings.playSounds) + + // Toggle off + testEnv.settingsManager.settings.playSounds = false + XCTAssertFalse(testEnv.settingsManager.settings.playSounds) + } + + func testLaunchAtLoginToggle() { + // Toggle on + testEnv.settingsManager.settings.launchAtLogin = true + XCTAssertTrue(testEnv.settingsManager.settings.launchAtLogin) + + // Toggle off + testEnv.settingsManager.settings.launchAtLogin = false + XCTAssertFalse(testEnv.settingsManager.settings.launchAtLogin) + } + + func testMultipleSettingsConfiguration() { + testEnv.settingsManager.settings.playSounds = true + testEnv.settingsManager.settings.launchAtLogin = true + + XCTAssertTrue(testEnv.settingsManager.settings.playSounds) + XCTAssertTrue(testEnv.settingsManager.settings.launchAtLogin) + + testEnv.settingsManager.settings.playSounds = false + XCTAssertFalse(testEnv.settingsManager.settings.playSounds) + XCTAssertTrue(testEnv.settingsManager.settings.launchAtLogin) + } + + func testGeneralAccessibilityIdentifier() { + XCTAssertEqual( + AccessibilityIdentifiers.Onboarding.generalPage, + "onboarding.page.general" + ) + } +} diff --git a/GazeTests/Views/LookAwaySetupViewTests.swift b/GazeTests/Views/LookAwaySetupViewTests.swift new file mode 100644 index 0000000..23645a2 --- /dev/null +++ b/GazeTests/Views/LookAwaySetupViewTests.swift @@ -0,0 +1,82 @@ +// +// LookAwaySetupViewTests.swift +// GazeTests +// +// Tests for LookAwaySetupView component. +// + +import SwiftUI +import XCTest +@testable import Gaze + +@MainActor +final class LookAwaySetupViewTests: XCTestCase { + + var testEnv: TestEnvironment! + + override func setUp() async throws { + testEnv = TestEnvironment() + } + + override func tearDown() async throws { + testEnv = nil + } + + func testLookAwaySetupInitialization() { + let view = LookAwaySetupView( + settingsManager: testEnv.settingsManager as! SettingsManager + ) + XCTAssertNotNil(view) + } + + func testLookAwayTimerConfigurationChanges() { + // Start with default + let initial = testEnv.settingsManager.timerConfiguration(for: .lookAway) + + // Modify configuration + var modified = initial + modified.enabled = true + modified.intervalSeconds = 1500 + testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: modified) + + // Verify changes + let updated = testEnv.settingsManager.timerConfiguration(for: .lookAway) + XCTAssertTrue(updated.enabled) + XCTAssertEqual(updated.intervalSeconds, 1500) + } + + func testLookAwayTimerEnableDisable() { + var config = testEnv.settingsManager.timerConfiguration(for: .lookAway) + + // Enable + config.enabled = true + testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + XCTAssertTrue(testEnv.settingsManager.timerConfiguration(for: .lookAway).enabled) + + // Disable + config.enabled = false + testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + XCTAssertFalse(testEnv.settingsManager.timerConfiguration(for: .lookAway).enabled) + } + + func testLookAwayIntervalValidation() { + var config = testEnv.settingsManager.timerConfiguration(for: .lookAway) + + // Test various intervals + let intervals = [300, 600, 1200, 1800, 3600] + for interval in intervals { + config.intervalSeconds = interval + testEnv.settingsManager.updateTimerConfiguration(for: .lookAway, configuration: config) + + let retrieved = testEnv.settingsManager.timerConfiguration(for: .lookAway) + XCTAssertEqual(retrieved.intervalSeconds, interval) + } + } + + func testLookAwayAccessibilityIdentifier() { + XCTAssertEqual( + AccessibilityIdentifiers.Onboarding.lookAwayPage, + "onboarding.page.lookAway" + ) + } +} diff --git a/GazeTests/Views/PostureSetupViewTests.swift b/GazeTests/Views/PostureSetupViewTests.swift new file mode 100644 index 0000000..5032cc7 --- /dev/null +++ b/GazeTests/Views/PostureSetupViewTests.swift @@ -0,0 +1,76 @@ +// +// PostureSetupViewTests.swift +// GazeTests +// +// Tests for PostureSetupView component. +// + +import SwiftUI +import XCTest +@testable import Gaze + +@MainActor +final class PostureSetupViewTests: XCTestCase { + + var testEnv: TestEnvironment! + + override func setUp() async throws { + testEnv = TestEnvironment() + } + + override func tearDown() async throws { + testEnv = nil + } + + func testPostureSetupInitialization() { + let view = PostureSetupView( + settingsManager: testEnv.settingsManager as! SettingsManager + ) + XCTAssertNotNil(view) + } + + func testPostureTimerConfigurationChanges() { + let initial = testEnv.settingsManager.timerConfiguration(for: .posture) + + var modified = initial + modified.enabled = true + modified.intervalSeconds = 1800 + testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: modified) + + let updated = testEnv.settingsManager.timerConfiguration(for: .posture) + XCTAssertTrue(updated.enabled) + XCTAssertEqual(updated.intervalSeconds, 1800) + } + + func testPostureTimerEnableDisable() { + var config = testEnv.settingsManager.timerConfiguration(for: .posture) + + config.enabled = true + testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: config) + XCTAssertTrue(testEnv.settingsManager.timerConfiguration(for: .posture).enabled) + + config.enabled = false + testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: config) + XCTAssertFalse(testEnv.settingsManager.timerConfiguration(for: .posture).enabled) + } + + func testPostureIntervalValidation() { + var config = testEnv.settingsManager.timerConfiguration(for: .posture) + + let intervals = [900, 1200, 1800, 2400, 3600] + for interval in intervals { + config.intervalSeconds = interval + testEnv.settingsManager.updateTimerConfiguration(for: .posture, configuration: config) + + let retrieved = testEnv.settingsManager.timerConfiguration(for: .posture) + XCTAssertEqual(retrieved.intervalSeconds, interval) + } + } + + func testPostureAccessibilityIdentifier() { + XCTAssertEqual( + AccessibilityIdentifiers.Onboarding.posturePage, + "onboarding.page.posture" + ) + } +} diff --git a/GazeTests/Views/WelcomeViewTests.swift b/GazeTests/Views/WelcomeViewTests.swift new file mode 100644 index 0000000..70263d1 --- /dev/null +++ b/GazeTests/Views/WelcomeViewTests.swift @@ -0,0 +1,28 @@ +// +// WelcomeViewTests.swift +// GazeTests +// +// Tests for WelcomeView component. +// + +import SwiftUI +import XCTest +@testable import Gaze + +@MainActor +final class WelcomeViewTests: XCTestCase { + + func testWelcomeViewInitialization() { + let view = WelcomeView() + XCTAssertNotNil(view) + } + + func testWelcomeViewHasAccessibilityIdentifier() { + // Welcome view should have proper accessibility identifier + // This is a structural test - in real app, verify the view has the identifier + XCTAssertEqual( + AccessibilityIdentifiers.Onboarding.welcomePage, + "onboarding.page.welcome" + ) + } +}