diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index 1f045e1..bcdfa09 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -28,11 +28,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { self.windowManager = WindowManager.shared super.init() - // Setup window close observers - setupWindowCloseObservers() } - /// Initializer for testing with injectable dependencies init(serviceContainer: ServiceContainer, windowManager: WindowManaging) { self.serviceContainer = serviceContainer self.windowManager = windowManager @@ -40,16 +37,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } func applicationDidFinishLaunching(_ notification: Notification) { - // Set activation policy to hide dock icon NSApplication.shared.setActivationPolicy(.accessory) - logInfo("🚀 Application did finish launching") - timerEngine = serviceContainer.timerEngine serviceContainer.setupSmartModeServices() - // Initialize update manager after onboarding is complete if settingsManager.settings.hasCompletedOnboarding { updateManager = UpdateManager.shared @@ -62,24 +55,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { if settingsManager.settings.hasCompletedOnboarding { startTimers() } - - // DEBUG: Auto-start eye tracking test mode if launch argument is present - #if DEBUG - if CommandLine.arguments.contains("--debug-eye-tracking") { - NSLog("🔬 DEBUG: Auto-starting eye tracking test mode") - Task { @MainActor in - // Enable enforce mode if not already - if !settingsManager.settings.enforcementMode { - settingsManager.settings.enforcementMode = true - } - // Start test mode after a brief delay - try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second - NSLog("🔬 DEBUG: Starting test mode now...") - await EnforceModeService.shared.startTestMode() - NSLog("🔬 DEBUG: Test mode started") - } - } - #endif } // Note: Smart mode setup is now handled by ServiceContainer @@ -105,7 +80,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { func onboardingCompleted() { startTimers() - // Start update checks after onboarding if updateManager == nil { updateManager = UpdateManager.shared } @@ -249,24 +223,4 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } } - private func setupWindowCloseObservers() { - NotificationCenter.default.addObserver( - self, - selector: #selector(settingsWindowDidClose), - name: Notification.Name("SettingsWindowDidClose"), - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(onboardingWindowDidClose), - name: Notification.Name("OnboardingWindowDidClose"), - object: nil - ) - } - - @objc private func settingsWindowDidClose() {} - - @objc private func onboardingWindowDidClose() {} - } diff --git a/Gaze/GazeApp.swift b/Gaze/GazeApp.swift index 4175464..1fadd1d 100644 --- a/Gaze/GazeApp.swift +++ b/Gaze/GazeApp.swift @@ -41,7 +41,7 @@ struct GazeApp: App { } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) - .defaultSize(width: 700, height: 700) + .defaultSize(width: 1000, height: 700) .commands { CommandGroup(replacing: .newItem) {} } diff --git a/Gaze/Views/Components/CalibrationOverlayView.swift b/Gaze/Views/Components/CalibrationOverlayView.swift index 04d57eb..5651fa9 100644 --- a/Gaze/Views/Components/CalibrationOverlayView.swift +++ b/Gaze/Views/Components/CalibrationOverlayView.swift @@ -5,22 +5,22 @@ // Fullscreen overlay view for eye tracking calibration targets. // -import SwiftUI -import Combine import AVFoundation +import Combine +import SwiftUI struct CalibrationOverlayView: View { @StateObject private var calibrationManager = CalibrationManager.shared @StateObject private var eyeTrackingService = EyeTrackingService.shared @StateObject private var viewModel = CalibrationOverlayViewModel() - + let onDismiss: () -> Void - + var body: some View { GeometryReader { geometry in ZStack { Color.black.ignoresSafeArea() - + // Camera preview at 50% opacity (mirrored for natural feel) if let previewLayer = eyeTrackingService.previewLayer { CameraPreviewView(previewLayer: previewLayer, borderColor: .clear) @@ -28,14 +28,16 @@ struct CalibrationOverlayView: View { .opacity(0.5) .ignoresSafeArea() } - + if let error = viewModel.showError { errorView(error) } else if !viewModel.cameraStarted { startingCameraView } else if calibrationManager.isCalibrating { calibrationContentView(screenSize: geometry.size) - } else if viewModel.calibrationStarted && calibrationManager.calibrationData.isComplete { + } else if viewModel.calibrationStarted + && calibrationManager.calibrationData.isComplete + { // Only show completion if we started calibration this session AND it completed completionView } else if viewModel.calibrationStarted { @@ -45,10 +47,12 @@ struct CalibrationOverlayView: View { } } .task { - await viewModel.startCamera(eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) + await viewModel.startCamera( + eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) } .onDisappear { - viewModel.cleanup(eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) + viewModel.cleanup( + eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) } .onChange(of: calibrationManager.currentStep) { oldStep, newStep in if newStep != nil && oldStep != newStep { @@ -56,38 +60,38 @@ struct CalibrationOverlayView: View { } } } - + // MARK: - Starting Camera View - + private var startingCameraView: some View { VStack(spacing: 20) { ProgressView() .scaleEffect(2) .tint(.white) - + Text("Starting camera...") .font(.title2) .foregroundStyle(.white) } } - + // MARK: - Error View - + private func errorView(_ message: String) -> some View { VStack(spacing: 20) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 60)) .foregroundStyle(.orange) - + Text("Camera Error") .font(.title) .foregroundStyle(.white) - + Text(message) .font(.body) .foregroundStyle(.gray) .multilineTextAlignment(.center) - + Button("Close") { onDismiss() } @@ -96,20 +100,20 @@ struct CalibrationOverlayView: View { } .padding(40) } - + // MARK: - Calibration Content - + private func calibrationContentView(screenSize: CGSize) -> some View { ZStack { VStack { progressBar Spacer() } - + if let step = calibrationManager.currentStep { calibrationTarget(for: step, screenSize: screenSize) } - + VStack { Spacer() HStack { @@ -122,7 +126,7 @@ struct CalibrationOverlayView: View { .padding(.horizontal, 40) .padding(.bottom, 40) } - + // Face detection indicator VStack { HStack { @@ -133,9 +137,9 @@ struct CalibrationOverlayView: View { } } } - + // MARK: - Progress Bar - + private var progressBar: some View { VStack(spacing: 10) { HStack { @@ -145,7 +149,7 @@ struct CalibrationOverlayView: View { Text(calibrationManager.progressText) .foregroundStyle(.white.opacity(0.7)) } - + ProgressView(value: calibrationManager.progress) .progressViewStyle(.linear) .tint(.blue) @@ -153,15 +157,15 @@ struct CalibrationOverlayView: View { .padding() .background(Color.black.opacity(0.7)) } - + // MARK: - Face Detection Indicator - + private var faceDetectionIndicator: some View { HStack(spacing: 8) { Circle() .fill(viewModel.stableFaceDetected ? Color.green : Color.red) .frame(width: 12, height: 12) - + Text(viewModel.stableFaceDetected ? "Face detected" : "No face detected") .font(.caption) .foregroundStyle(.white.opacity(0.8)) @@ -173,13 +177,13 @@ struct CalibrationOverlayView: View { .padding() .animation(.easeInOut(duration: 0.3), value: viewModel.stableFaceDetected) } - + // MARK: - Calibration Target - + @ViewBuilder private func calibrationTarget(for step: CalibrationStep, screenSize: CGSize) -> some View { let position = targetPosition(for: step, screenSize: screenSize) - + VStack(spacing: 20) { ZStack { // Outer ring (pulsing when counting down) @@ -188,11 +192,11 @@ struct CalibrationOverlayView: View { .frame(width: 100, height: 100) .scaleEffect(viewModel.isCountingDown ? 1.2 : 1.0) .animation( - viewModel.isCountingDown + viewModel.isCountingDown ? .easeInOut(duration: 0.6).repeatForever(autoreverses: true) : .default, value: viewModel.isCountingDown) - + // Progress ring when collecting if calibrationManager.isCollectingSamples { Circle() @@ -200,15 +204,17 @@ struct CalibrationOverlayView: View { .stroke(Color.green, lineWidth: 4) .frame(width: 90, height: 90) .rotationEffect(.degrees(-90)) - .animation(.linear(duration: 0.1), value: calibrationManager.samplesCollected) + .animation( + .linear(duration: 0.1), value: calibrationManager.samplesCollected) } - + // Inner circle Circle() .fill(calibrationManager.isCollectingSamples ? Color.green : Color.blue) .frame(width: 60, height: 60) - .animation(.easeInOut(duration: 0.3), value: calibrationManager.isCollectingSamples) - + .animation( + .easeInOut(duration: 0.3), value: calibrationManager.isCollectingSamples) + // Countdown number or collecting indicator if viewModel.isCountingDown && viewModel.countdownValue > 0 { Text("\(viewModel.countdownValue)") @@ -220,7 +226,7 @@ struct CalibrationOverlayView: View { .foregroundStyle(.white) } } - + Text(instructionText(for: step)) .font(.title2) .foregroundStyle(.white) @@ -231,7 +237,7 @@ struct CalibrationOverlayView: View { } .position(position) } - + private func instructionText(for step: CalibrationStep) -> String { if viewModel.isCountingDown && viewModel.countdownValue > 0 { return "Get ready..." @@ -241,9 +247,9 @@ struct CalibrationOverlayView: View { return step.instructionText } } - + // MARK: - Buttons - + private var skipButton: some View { Button { viewModel.skipCurrentStep(calibrationManager: calibrationManager) @@ -257,10 +263,11 @@ struct CalibrationOverlayView: View { } .buttonStyle(.plain) } - + private var cancelButton: some View { Button { - viewModel.cleanup(eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) + viewModel.cleanup( + eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) onDismiss() } label: { HStack(spacing: 6) { @@ -276,24 +283,24 @@ struct CalibrationOverlayView: View { .buttonStyle(.plain) .keyboardShortcut(.escape, modifiers: []) } - + // MARK: - Completion View - + private var completionView: some View { VStack(spacing: 30) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 80)) .foregroundStyle(.green) - + Text("Calibration Complete!") .font(.largeTitle) .foregroundStyle(.white) .fontWeight(.bold) - + Text("Your eye tracking has been calibrated successfully.") .font(.title3) .foregroundStyle(.gray) - + Button("Done") { onDismiss() } @@ -307,18 +314,18 @@ struct CalibrationOverlayView: View { } } } - + // MARK: - Helper Methods - + private func targetPosition(for step: CalibrationStep, screenSize: CGSize) -> CGPoint { let width = screenSize.width let height = screenSize.height - + let centerX = width / 2 let centerY = height / 2 let marginX: CGFloat = 150 let marginY: CGFloat = 120 - + switch step { case .center: return CGPoint(x: centerX, y: centerY) @@ -356,23 +363,24 @@ class CalibrationOverlayViewModel: ObservableObject { @Published var showError: String? @Published var calibrationStarted = false @Published var stableFaceDetected = false // Debounced face detection - + private var countdownTask: Task? private var faceDetectionCancellable: AnyCancellable? private var lastFaceDetectedTime: Date = .distantPast private let faceDetectionDebounce: TimeInterval = 0.5 // 500ms debounce - - func startCamera(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager) async { + + func startCamera(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager) + async + { do { try await eyeTrackingService.startEyeTracking() cameraStarted = true - + // Set up debounced face detection setupFaceDetectionObserver(eyeTrackingService: eyeTrackingService) - - // Small delay to let camera stabilize - try? await Task.sleep(nanoseconds: 500_000_000) - + + try? await Task.sleep(for: .seconds(0.5)) + // Reset any previous calibration data before starting fresh calibrationManager.resetForNewCalibration() calibrationManager.startCalibration() @@ -382,13 +390,13 @@ class CalibrationOverlayViewModel: ObservableObject { showError = "Failed to start camera: \(error.localizedDescription)" } } - + private func setupFaceDetectionObserver(eyeTrackingService: EyeTrackingService) { faceDetectionCancellable = eyeTrackingService.$faceDetected .receive(on: DispatchQueue.main) .sink { [weak self] detected in guard let self = self else { return } - + if detected { // Face detected - update immediately self.lastFaceDetectedTime = Date() @@ -402,39 +410,39 @@ class CalibrationOverlayViewModel: ObservableObject { } } } - + func cleanup(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager) { countdownTask?.cancel() countdownTask = nil faceDetectionCancellable?.cancel() faceDetectionCancellable = nil isCountingDown = false - + if calibrationManager.isCalibrating { calibrationManager.cancelCalibration() } - + eyeTrackingService.stopEyeTracking() } - + func skipCurrentStep(calibrationManager: CalibrationManager) { countdownTask?.cancel() countdownTask = nil isCountingDown = false calibrationManager.skipStep() } - + func startStepCountdown(calibrationManager: CalibrationManager) { countdownTask?.cancel() countdownTask = nil countdownValue = 1 isCountingDown = true - + countdownTask = Task { @MainActor in // Just 1 second countdown try? await Task.sleep(for: .seconds(1)) if Task.isCancelled { return } - + // Done counting, start collecting isCountingDown = false countdownValue = 0 @@ -446,4 +454,3 @@ class CalibrationOverlayViewModel: ObservableObject { #Preview { CalibrationOverlayView(onDismiss: {}) } - diff --git a/Gaze/Views/Containers/OnboardingContainerView.swift b/Gaze/Views/Containers/OnboardingContainerView.swift index 76e5d93..1a4747d 100644 --- a/Gaze/Views/Containers/OnboardingContainerView.swift +++ b/Gaze/Views/Containers/OnboardingContainerView.swift @@ -47,7 +47,7 @@ final class OnboardingWindowPresenter { windowController = nil return false } - + DispatchQueue.main.async { NSApp.unhide(nil) NSApp.activate(ignoringOtherApps: true) @@ -83,7 +83,9 @@ final class OnboardingWindowPresenter { window.titlebarAppearsTransparent = true window.center() window.isReleasedWhenClosed = true - window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary] + window.collectionBehavior = [ + .managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary, + ] window.contentView = NSHostingView( rootView: OnboardingContainerView(settingsManager: settingsManager) diff --git a/GazeTests/AppDelegateTestabilityTests.swift b/GazeTests/AppDelegateTestabilityTests.swift index c6ab22c..09354cc 100644 --- a/GazeTests/AppDelegateTestabilityTests.swift +++ b/GazeTests/AppDelegateTestabilityTests.swift @@ -6,92 +6,89 @@ // 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 + + try await Task.sleep(for: .milliseconds(100)) + 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 - + + try await Task.sleep(for: .milliseconds(100)) + // 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 - + + try await Task.sleep(for: .milliseconds(50)) + // 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))) + 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/Services/TimerEngineTests.swift b/GazeTests/Services/TimerEngineTests.swift index 150c169..76fcf4a 100644 --- a/GazeTests/Services/TimerEngineTests.swift +++ b/GazeTests/Services/TimerEngineTests.swift @@ -7,36 +7,37 @@ 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( @@ -44,174 +45,173 @@ final class TimerEngineTests: XCTestCase { enforceModeService: nil, 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) - + + try await Task.sleep(for: .milliseconds(50)) + 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 @@ -220,15 +220,15 @@ final class TimerEngineTests: XCTestCase { } } .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 @@ -237,67 +237,67 @@ final class TimerEngineTests: XCTestCase { } } .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/TestHelpers.swift b/GazeTests/TestHelpers.swift index 2e23cfd..81d39c8 100644 --- a/GazeTests/TestHelpers.swift +++ b/GazeTests/TestHelpers.swift @@ -5,8 +5,11 @@ // Test helpers and utilities for unit testing. // +// MARK: - Import Statement for Combine +import Combine import Foundation import XCTest + @testable import Gaze // MARK: - Enhanced MockSettingsManager @@ -16,21 +19,22 @@ import XCTest @Observable final class EnhancedMockSettingsManager: SettingsProviding { var settings: AppSettings - + @ObservationIgnored private let _settingsSubject: CurrentValueSubject - + var settingsPublisher: AnyPublisher { _settingsSubject.eraseToAnyPublisher() } - + @ObservationIgnored - private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ - .lookAway: \.lookAwayTimer, - .blink: \.blinkTimer, - .posture: \.postureTimer, - ] - + private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = + [ + .lookAway: \.lookAwayTimer, + .blink: \.blinkTimer, + .posture: \.postureTimer, + ] + // Track method calls for verification @ObservationIgnored private(set) var saveCallCount = 0 @@ -40,19 +44,19 @@ final class EnhancedMockSettingsManager: SettingsProviding { private(set) var loadCallCount = 0 @ObservationIgnored private(set) var resetToDefaultsCallCount = 0 - + init(settings: AppSettings = .defaults) { self.settings = settings self._settingsSubject = CurrentValueSubject(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)") @@ -60,7 +64,7 @@ final class EnhancedMockSettingsManager: SettingsProviding { settings[keyPath: keyPath] = configuration _settingsSubject.send(settings) } - + func allTimerConfigurations() -> [TimerType: TimerConfiguration] { var configs: [TimerType: TimerConfiguration] = [:] for (type, keyPath) in timerConfigKeyPaths { @@ -68,27 +72,27 @@ final class EnhancedMockSettingsManager: SettingsProviding { } return configs } - + func save() { saveCallCount += 1 _settingsSubject.send(settings) } - + func saveImmediately() { saveImmediatelyCallCount += 1 _settingsSubject.send(settings) } - + func load() { loadCallCount += 1 } - + func resetToDefaults() { resetToDefaultsCallCount += 1 settings = .defaults _settingsSubject.send(settings) } - + // Test helpers func reset() { saveCallCount = 0 @@ -105,17 +109,17 @@ final class EnhancedMockSettingsManager: SettingsProviding { @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 } @@ -124,22 +128,22 @@ final class MockFullscreenDetectionService: ObservableObject, FullscreenDetectio @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 } @@ -156,7 +160,7 @@ extension AppSettings { settings.postureTimer.enabled = false return settings } - + /// Settings with only lookAway timer enabled static var onlyLookAwayEnabled: AppSettings { var settings = AppSettings.defaults @@ -165,7 +169,7 @@ extension AppSettings { settings.postureTimer.enabled = false return settings } - + /// Settings with short intervals for testing static var shortIntervals: AppSettings { var settings = AppSettings.defaults @@ -174,14 +178,14 @@ extension AppSettings { 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 @@ -209,19 +213,19 @@ struct TestEnvironment { 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() @@ -245,10 +249,10 @@ extension XCTestCase { XCTFail(message) return } - try await Task.sleep(nanoseconds: 10_000_000) // 10ms + try await Task.sleep(for: .milliseconds(100)) } } - + /// Waits for a published value to change @MainActor func waitForPublisher( @@ -258,17 +262,14 @@ extension XCTestCase { ) 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 index 24d1a1e..b13cf2b 100644 --- a/GazeTests/TimerEngineTestabilityTests.swift +++ b/GazeTests/TimerEngineTestabilityTests.swift @@ -7,24 +7,25 @@ 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( @@ -32,30 +33,30 @@ final class TimerEngineTestabilityTests: XCTestCase { enforceModeService: nil, 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( @@ -63,52 +64,51 @@ final class TimerEngineTestabilityTests: XCTestCase { enforceModeService: nil, 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) - + + try await Task.sleep(for: .milliseconds(10)) + XCTAssertNotNil(receivedReminder) } }