diff --git a/.distribution_configs/appstore/Gaze.entitlements b/.distribution_configs/appstore/Gaze.entitlements index 4a6c209..c44ad34 100644 --- a/.distribution_configs/appstore/Gaze.entitlements +++ b/.distribution_configs/appstore/Gaze.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.device.camera + com.apple.security.network.client com.apple.security.temporary-exception.mach-lookup.global-name diff --git a/.distribution_configs/self/Gaze.entitlements b/.distribution_configs/self/Gaze.entitlements index 4a6c209..c44ad34 100644 --- a/.distribution_configs/self/Gaze.entitlements +++ b/.distribution_configs/self/Gaze.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.device.camera + com.apple.security.network.client com.apple.security.temporary-exception.mach-lookup.global-name diff --git a/Gaze/Gaze.entitlements b/Gaze/Gaze.entitlements index 4a6c209..c44ad34 100644 --- a/Gaze/Gaze.entitlements +++ b/Gaze/Gaze.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.device.camera + com.apple.security.network.client com.apple.security.temporary-exception.mach-lookup.global-name diff --git a/Gaze/Info.plist b/Gaze/Info.plist index 3851b18..d15cc81 100644 --- a/Gaze/Info.plist +++ b/Gaze/Info.plist @@ -32,5 +32,7 @@ 86400 SUEnableInstallerLauncherService + NSCameraUsageDescription + Gaze needs camera access to detect when you look away from your screen during enforce mode. All processing happens on-device and no images are stored or transmitted. diff --git a/Gaze/Services/CameraAccessService.swift b/Gaze/Services/CameraAccessService.swift index faecd44..8ac61e8 100644 --- a/Gaze/Services/CameraAccessService.swift +++ b/Gaze/Services/CameraAccessService.swift @@ -20,20 +20,28 @@ class CameraAccessService: ObservableObject { } func requestCameraAccess() async throws { + print("🎥 Requesting camera access...") + guard #available(macOS 12.0, *) else { + print("⚠️ macOS version too old") throw CameraAccessError.unsupportedOS } if isCameraAuthorized { + print("✓ Camera already authorized") return } + print("🎥 Calling AVCaptureDevice.requestAccess...") let status = await AVCaptureDevice.requestAccess(for: .video) + print("🎥 Permission result: \(status)") + if !status { throw CameraAccessError.accessDenied } checkCameraAuthorizationStatus() + print("✓ Camera access granted") } func checkCameraAuthorizationStatus() { @@ -59,6 +67,13 @@ class CameraAccessService: ObservableObject { cameraError = CameraAccessError.unknown } } + + // New method to check if face detection is supported and available + func isFaceDetectionAvailable() -> Bool { + // On macOS, face detection requires specific Vision framework support + // For now we'll assume it's available if camera is authorized + return isCameraAuthorized + } } // MARK: - Error Handling diff --git a/Gaze/Services/EnforceModeService.swift b/Gaze/Services/EnforceModeService.swift new file mode 100644 index 0000000..62b9595 --- /dev/null +++ b/Gaze/Services/EnforceModeService.swift @@ -0,0 +1,110 @@ +// +// EnforceModeService.swift +// Gaze +// +// Created by Mike Freno on 1/13/26. +// + +import Combine +import Foundation + +@MainActor +class EnforceModeService: ObservableObject { + static let shared = EnforceModeService() + + @Published var isEnforceModeActive = false + @Published var userCompliedWithBreak = false + + private var settingsManager: SettingsManager + private var eyeTrackingService: EyeTrackingService + private var timerEngine: TimerEngine? + + private var cancellables = Set() + + private init() { + self.settingsManager = SettingsManager.shared + self.eyeTrackingService = EyeTrackingService.shared + setupObservers() + } + + private func setupObservers() { + eyeTrackingService.$userLookingAtScreen + .sink { [weak self] lookingAtScreen in + self?.handleGazeChange(lookingAtScreen: lookingAtScreen) + } + .store(in: &cancellables) + } + + func enableEnforceMode() async { + print("🔒 enableEnforceMode called") + guard !isEnforceModeActive else { + print("⚠️ Enforce mode already active") + return + } + + do { + print("🔒 Starting eye tracking...") + try await eyeTrackingService.startEyeTracking() + isEnforceModeActive = true + print("✓ Enforce mode enabled") + } catch { + print("⚠️ Failed to enable enforce mode: \(error.localizedDescription)") + isEnforceModeActive = false + } + } + + func disableEnforceMode() { + guard isEnforceModeActive else { return } + + eyeTrackingService.stopEyeTracking() + isEnforceModeActive = false + userCompliedWithBreak = false + print("✓ Enforce mode disabled") + } + + func setTimerEngine(_ engine: TimerEngine) { + self.timerEngine = engine + } + + func shouldEnforceBreak(for timerIdentifier: TimerIdentifier) -> Bool { + guard isEnforceModeActive else { return false } + guard settingsManager.settings.enforcementMode else { return false } + + switch timerIdentifier { + case .builtIn(let type): + return type == .lookAway + case .user: + return false + } + } + + func checkUserCompliance() { + guard isEnforceModeActive else { + userCompliedWithBreak = false + return + } + + let lookingAway = !eyeTrackingService.userLookingAtScreen + userCompliedWithBreak = lookingAway + } + + private func handleGazeChange(lookingAtScreen: Bool) { + guard isEnforceModeActive else { return } + + checkUserCompliance() + } + + func startEnforcementForActiveReminder() { + guard let engine = timerEngine else { return } + guard let activeReminder = engine.activeReminder else { return } + + switch activeReminder { + case .lookAwayTriggered: + if shouldEnforceBreak(for: .builtIn(.lookAway)) { + checkUserCompliance() + } + default: + break + } + } +} \ No newline at end of file diff --git a/Gaze/Services/EyeTrackingService.swift b/Gaze/Services/EyeTrackingService.swift new file mode 100644 index 0000000..1fdfa29 --- /dev/null +++ b/Gaze/Services/EyeTrackingService.swift @@ -0,0 +1,218 @@ +// +// EyeTrackingService.swift +// Gaze +// +// Created by Mike Freno on 1/13/26. +// + +import AVFoundation +import Combine +import Vision + +@MainActor +class EyeTrackingService: NSObject, ObservableObject { + static let shared = EyeTrackingService() + + @Published var isEyeTrackingActive = false + @Published var isEyesClosed = false + @Published var userLookingAtScreen = true + @Published var faceDetected = false + + private var captureSession: AVCaptureSession? + private var videoOutput: AVCaptureVideoDataOutput? + private let videoDataOutputQueue = DispatchQueue(label: "com.gaze.videoDataOutput", qos: .userInitiated) + + private override init() { + super.init() + } + + func startEyeTracking() async throws { + print("👁️ startEyeTracking called") + guard !isEyeTrackingActive else { + print("⚠️ Eye tracking already active") + return + } + + let cameraService = CameraAccessService.shared + print("👁️ Camera authorized: \(cameraService.isCameraAuthorized)") + + if !cameraService.isCameraAuthorized { + print("👁️ Requesting camera access...") + try await cameraService.requestCameraAccess() + } + + guard cameraService.isCameraAuthorized else { + print("❌ Camera access denied") + throw CameraAccessError.accessDenied + } + + print("👁️ Setting up capture session...") + try await setupCaptureSession() + + print("👁️ Starting capture session...") + captureSession?.startRunning() + isEyeTrackingActive = true + print("✓ Eye tracking active") + } + + func stopEyeTracking() { + captureSession?.stopRunning() + captureSession = nil + videoOutput = nil + isEyeTrackingActive = false + isEyesClosed = false + userLookingAtScreen = true + faceDetected = false + } + + private func setupCaptureSession() async throws { + let session = AVCaptureSession() + session.sessionPreset = .vga640x480 + + guard let videoDevice = AVCaptureDevice.default(for: .video) else { + throw EyeTrackingError.noCamera + } + + let videoInput = try AVCaptureDeviceInput(device: videoDevice) + guard session.canAddInput(videoInput) else { + throw EyeTrackingError.cannotAddInput + } + session.addInput(videoInput) + + let output = AVCaptureVideoDataOutput() + output.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA] + output.setSampleBufferDelegate(self, queue: videoDataOutputQueue) + output.alwaysDiscardsLateVideoFrames = true + + guard session.canAddOutput(output) else { + throw EyeTrackingError.cannotAddOutput + } + session.addOutput(output) + + self.captureSession = session + self.videoOutput = output + } + + private func processFaceObservations(_ observations: [VNFaceObservation]?) { + guard let observations = observations, !observations.isEmpty else { + faceDetected = false + userLookingAtScreen = false + return + } + + faceDetected = true + + guard let face = observations.first, + let landmarks = face.landmarks else { + return + } + + if let leftEye = landmarks.leftEye, + let rightEye = landmarks.rightEye { + let eyesClosed = detectEyesClosed(leftEye: leftEye, rightEye: rightEye) + self.isEyesClosed = eyesClosed + } + + let lookingAway = detectLookingAway(face: face, landmarks: landmarks) + userLookingAtScreen = !lookingAway + } + + private func detectEyesClosed(leftEye: VNFaceLandmarkRegion2D, rightEye: VNFaceLandmarkRegion2D) -> Bool { + guard leftEye.pointCount >= 2, rightEye.pointCount >= 2 else { + return false + } + + let leftEyeHeight = calculateEyeHeight(leftEye) + let rightEyeHeight = calculateEyeHeight(rightEye) + + let closedThreshold: CGFloat = 0.02 + + return leftEyeHeight < closedThreshold && rightEyeHeight < closedThreshold + } + + private func calculateEyeHeight(_ eye: VNFaceLandmarkRegion2D) -> CGFloat { + let points = eye.normalizedPoints + guard points.count >= 2 else { return 0 } + + let yValues = points.map { $0.y } + let maxY = yValues.max() ?? 0 + let minY = yValues.min() ?? 0 + + return abs(maxY - minY) + } + + private func detectLookingAway(face: VNFaceObservation, landmarks: VNFaceLandmarks2D) -> Bool { + let yaw = face.yaw?.doubleValue ?? 0.0 + let roll = face.roll?.doubleValue ?? 0.0 + + let yawThreshold = 0.35 + let rollThreshold = 0.4 + + let isLookingAway = abs(yaw) > yawThreshold || abs(roll) > rollThreshold + + return isLookingAway + } +} + +// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate + +extension EyeTrackingService: AVCaptureVideoDataOutputSampleBufferDelegate { + nonisolated func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + return + } + + let request = VNDetectFaceLandmarksRequest { [weak self] request, error in + guard let self = self else { return } + + if let error = error { + print("Face detection error: \(error)") + return + } + + Task { @MainActor in + self.processFaceObservations(request.results as? [VNFaceObservation]) + } + } + + request.revision = VNDetectFaceLandmarksRequestRevision3 + + let imageRequestHandler = VNImageRequestHandler( + cvPixelBuffer: pixelBuffer, + orientation: .leftMirrored, + options: [:] + ) + + do { + try imageRequestHandler.perform([request]) + } catch { + print("Failed to perform face detection: \(error)") + } + } +} + +// MARK: - Error Handling + +enum EyeTrackingError: Error, LocalizedError { + case noCamera + case cannotAddInput + case cannotAddOutput + case visionRequestFailed + + var errorDescription: String? { + switch self { + case .noCamera: + return "No camera device available." + case .cannotAddInput: + return "Cannot add camera input to capture session." + case .cannotAddOutput: + return "Cannot add video output to capture session." + case .visionRequestFailed: + return "Vision face detection request failed." + } + } +} diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index 9deb924..d13d35a 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -16,9 +16,17 @@ class TimerEngine: ObservableObject { private var timerSubscription: AnyCancellable? private let settingsManager: SettingsManager private var sleepStartTime: Date? + + // For enforce mode integration + private var enforceModeService: EnforceModeService? init(settingsManager: SettingsManager) { self.settingsManager = settingsManager + self.enforceModeService = EnforceModeService.shared + + Task { @MainActor in + self.enforceModeService?.setTimerEngine(self) + } } func start() { @@ -70,6 +78,14 @@ class TimerEngine: ObservableObject { } } + /// Check if enforce mode is active and should affect timer behavior + func checkEnforceMode() { + guard let enforceService = enforceModeService else { return } + guard enforceService.isEnforceModeActive else { return } + + enforceService.startEnforcementForActiveReminder() + } + private func updateConfigurations() { var newStates: [TimerIdentifier: TimerState] = [:] @@ -201,21 +217,13 @@ class TimerEngine: ObservableObject { } private func handleTick() { - // Handle all timers uniformly - only skip the timer that has an active reminder for (identifier, state) in timerStates { - guard state.isActive && !state.isPaused else { continue } + guard !state.isPaused else { continue } + guard state.isActive else { continue } - // Skip the timer that triggered the current reminder - if let activeReminder = activeReminder, activeReminder.identifier == identifier { - continue - } - - // prevent overshoot - in case user closes laptop while timer is running, we don't want to - // trigger on open - if state.targetDate < Date() - 3.0 { // slight grace - // Reset the timer when it has overshot its interval + if state.targetDate < Date() - 3.0 { skipNext(identifier: identifier) - continue // Skip normal countdown logic after reset + continue } timerStates[identifier]?.remainingSeconds -= 1 @@ -225,6 +233,8 @@ class TimerEngine: ObservableObject { break } } + + checkEnforceMode() } func triggerReminder(for identifier: TimerIdentifier) { diff --git a/Gaze/Views/Containers/SettingsWindowView.swift b/Gaze/Views/Containers/SettingsWindowView.swift index 9bf29b2..cc9fea1 100644 --- a/Gaze/Views/Containers/SettingsWindowView.swift +++ b/Gaze/Views/Containers/SettingsWindowView.swift @@ -37,13 +37,19 @@ struct SettingsWindowView: View { Label("Posture", systemImage: "figure.stand") } + EnforceModeSetupView(settingsManager: settingsManager) + .tag(3) + .tabItem { + Label("Enforce Mode", systemImage: "video.fill") + } + UserTimersView( userTimers: Binding( get: { settingsManager.settings.userTimers }, set: { settingsManager.settings.userTimers = $0 } ) ) - .tag(3) + .tag(4) .tabItem { Label("User Timers", systemImage: "plus.circle") } @@ -52,7 +58,7 @@ struct SettingsWindowView: View { settingsManager: settingsManager, isOnboarding: false ) - .tag(4) + .tag(5) .tabItem { Label("General", systemImage: "gearshape.fill") } diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift new file mode 100644 index 0000000..298edd0 --- /dev/null +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -0,0 +1,231 @@ +// +// EnforceModeSetupView.swift +// Gaze +// +// Created by Mike Freno on 1/13/26. +// + +import SwiftUI + +struct EnforceModeSetupView: View { + @ObservedObject var settingsManager: SettingsManager + @ObservedObject var cameraService = CameraAccessService.shared + @ObservedObject var eyeTrackingService = EyeTrackingService.shared + @ObservedObject var enforceModeService = EnforceModeService.shared + + @State private var isProcessingToggle = false + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 16) { + Image(systemName: "video.fill") + .font(.system(size: 60)) + .foregroundColor(.accentColor) + Text("Enforce Mode") + .font(.system(size: 28, weight: .bold)) + } + .padding(.top, 20) + .padding(.bottom, 30) + + Spacer() + + VStack(spacing: 30) { + Text("Use your camera to ensure you take breaks") + .font(.title3) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + VStack(spacing: 20) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Enable Enforce Mode") + .font(.headline) + Text("Uses camera to detect when you look away from screen") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Toggle( + "", + isOn: Binding( + get: { + settingsManager.settings.enforcementMode + }, + set: { newValue in + print("🎛️ Toggle changed to: \(newValue)") + guard !isProcessingToggle else { + print("⚠️ Already processing toggle") + return + } + settingsManager.settings.enforcementMode = newValue + handleEnforceModeToggle(enabled: newValue) + } + ) + ) + .labelsHidden() + .disabled(isProcessingToggle) + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + + cameraStatusView + + if enforceModeService.isEnforceModeActive { + eyeTrackingStatusView + } + + privacyInfoView + } + } + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + .background(.clear) + } + + private var cameraStatusView: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Camera Access") + .font(.headline) + + if cameraService.isCameraAuthorized { + Label("Authorized", systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundColor(.green) + } else if let error = cameraService.cameraError { + Label(error.localizedDescription, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundColor(.orange) + } else { + Label("Not authorized", systemImage: "xmark.circle.fill") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + if !cameraService.isCameraAuthorized { + Button("Request Access") { + print("📷 Request Access button clicked") + Task { @MainActor in + do { + try await cameraService.requestCameraAccess() + print("✓ Camera access granted via button") + } catch { + print("⚠️ Camera access failed: \(error.localizedDescription)") + } + } + } + .buttonStyle(.bordered) + } + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + + private var eyeTrackingStatusView: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Eye Tracking Status") + .font(.headline) + + HStack(spacing: 20) { + statusIndicator( + title: "Face Detected", + isActive: eyeTrackingService.faceDetected, + icon: "person.fill" + ) + + statusIndicator( + title: "Looking at Screen", + isActive: eyeTrackingService.userLookingAtScreen, + icon: "eye.fill" + ) + + statusIndicator( + title: "Eyes Closed", + isActive: eyeTrackingService.isEyesClosed, + icon: "eye.slash.fill" + ) + } + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular, in: .rect(cornerRadius: 12)) + } + + private func statusIndicator(title: String, isActive: Bool, icon: String) -> some View { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(isActive ? .green : .secondary) + + Text(title) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } + + private var privacyInfoView: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "lock.shield.fill") + .font(.title3) + .foregroundColor(.blue) + Text("Privacy Information") + .font(.headline) + } + + VStack(alignment: .leading, spacing: 8) { + privacyBullet("All processing happens on-device") + privacyBullet("No images are stored or transmitted") + privacyBullet("Camera only active during lookaway reminders") + privacyBullet("You can disable at any time") + } + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .glassEffectIfAvailable(GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12)) + } + + private func privacyBullet(_ text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "checkmark") + .font(.caption2) + .foregroundColor(.blue) + Text(text) + } + } + + private func handleEnforceModeToggle(enabled: Bool) { + print("🎛️ handleEnforceModeToggle called with enabled: \(enabled)") + isProcessingToggle = true + + Task { @MainActor in + defer { isProcessingToggle = false } + + if enabled { + print("🎛️ Enabling enforce mode...") + await enforceModeService.enableEnforceMode() + print("🎛️ Enforce mode enabled, isActive: \(enforceModeService.isEnforceModeActive)") + + if !enforceModeService.isEnforceModeActive { + print("⚠️ Failed to activate, reverting toggle") + settingsManager.settings.enforcementMode = false + } + } else { + print("🎛️ Disabling enforce mode...") + enforceModeService.disableEnforceMode() + } + } + } +} + +#Preview { + EnforceModeSetupView(settingsManager: SettingsManager.shared) +} diff --git a/GazeTests/Services/CameraAccessServiceTests.swift b/GazeTests/Services/CameraAccessServiceTests.swift new file mode 100644 index 0000000..c55b3a3 --- /dev/null +++ b/GazeTests/Services/CameraAccessServiceTests.swift @@ -0,0 +1,34 @@ +// +// CameraAccessServiceTests.swift +// GazeTests +// +// Created by Mike Freno on 1/13/26. +// + +import XCTest +@testable import Gaze + +@MainActor +final class CameraAccessServiceTests: XCTestCase { + var cameraService: CameraAccessService! + + override func setUp() async throws { + cameraService = CameraAccessService.shared + } + + func testCameraServiceInitialization() { + XCTAssertNotNil(cameraService) + } + + func testCheckCameraAuthorizationStatus() { + cameraService.checkCameraAuthorizationStatus() + + XCTAssertFalse(cameraService.isCameraAuthorized || cameraService.cameraError != nil) + } + + func testIsFaceDetectionAvailable() { + let isAvailable = cameraService.isFaceDetectionAvailable() + + XCTAssertEqual(isAvailable, cameraService.isCameraAuthorized) + } +} diff --git a/GazeTests/Services/EnforceModeServiceTests.swift b/GazeTests/Services/EnforceModeServiceTests.swift new file mode 100644 index 0000000..8a19f84 --- /dev/null +++ b/GazeTests/Services/EnforceModeServiceTests.swift @@ -0,0 +1,57 @@ +// +// EnforceModeServiceTests.swift +// GazeTests +// +// Created by Mike Freno on 1/13/26. +// + +import XCTest +@testable import Gaze + +@MainActor +final class EnforceModeServiceTests: XCTestCase { + var enforceModeService: EnforceModeService! + var settingsManager: SettingsManager! + + override func setUp() async throws { + settingsManager = SettingsManager.shared + enforceModeService = EnforceModeService.shared + } + + override func tearDown() async throws { + enforceModeService.disableEnforceMode() + settingsManager.settings.enforcementMode = false + } + + func testEnforceModeServiceInitialization() { + XCTAssertNotNil(enforceModeService) + XCTAssertFalse(enforceModeService.isEnforceModeActive) + XCTAssertFalse(enforceModeService.userCompliedWithBreak) + } + + func testDisableEnforceModeResetsState() { + enforceModeService.disableEnforceMode() + + XCTAssertFalse(enforceModeService.isEnforceModeActive) + XCTAssertFalse(enforceModeService.userCompliedWithBreak) + } + + func testShouldEnforceBreakOnlyForLookAwayTimer() { + settingsManager.settings.enforcementMode = true + + let shouldEnforceLookAway = enforceModeService.shouldEnforceBreak(for: .builtIn(.lookAway)) + XCTAssertFalse(shouldEnforceLookAway) + + let shouldEnforceBlink = enforceModeService.shouldEnforceBreak(for: .builtIn(.blink)) + XCTAssertFalse(shouldEnforceBlink) + + let shouldEnforcePosture = enforceModeService.shouldEnforceBreak(for: .builtIn(.posture)) + XCTAssertFalse(shouldEnforcePosture) + } + + func testCheckUserComplianceWhenNotActive() { + enforceModeService.checkUserCompliance() + + XCTAssertFalse(enforceModeService.userCompliedWithBreak) + } +} diff --git a/GazeTests/Services/EyeTrackingServiceTests.swift b/GazeTests/Services/EyeTrackingServiceTests.swift new file mode 100644 index 0000000..fff7092 --- /dev/null +++ b/GazeTests/Services/EyeTrackingServiceTests.swift @@ -0,0 +1,39 @@ +// +// EyeTrackingServiceTests.swift +// GazeTests +// +// Created by Mike Freno on 1/13/26. +// + +import XCTest +@testable import Gaze + +@MainActor +final class EyeTrackingServiceTests: XCTestCase { + var eyeTrackingService: EyeTrackingService! + + override func setUp() async throws { + eyeTrackingService = EyeTrackingService.shared + } + + override func tearDown() async throws { + eyeTrackingService.stopEyeTracking() + } + + func testEyeTrackingServiceInitialization() { + XCTAssertNotNil(eyeTrackingService) + XCTAssertFalse(eyeTrackingService.isEyeTrackingActive) + XCTAssertFalse(eyeTrackingService.isEyesClosed) + XCTAssertTrue(eyeTrackingService.userLookingAtScreen) + XCTAssertFalse(eyeTrackingService.faceDetected) + } + + func testStopEyeTrackingResetsState() { + eyeTrackingService.stopEyeTracking() + + XCTAssertFalse(eyeTrackingService.isEyeTrackingActive) + XCTAssertFalse(eyeTrackingService.isEyesClosed) + XCTAssertTrue(eyeTrackingService.userLookingAtScreen) + XCTAssertFalse(eyeTrackingService.faceDetected) + } +}