general: basic cleanup

This commit is contained in:
Michael Freno
2026-01-17 09:09:09 -05:00
parent 03ab6160d2
commit a528a549b9
8 changed files with 259 additions and 298 deletions

View File

@@ -28,11 +28,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
self.windowManager = WindowManager.shared self.windowManager = WindowManager.shared
super.init() super.init()
// Setup window close observers
setupWindowCloseObservers()
} }
/// Initializer for testing with injectable dependencies
init(serviceContainer: ServiceContainer, windowManager: WindowManaging) { init(serviceContainer: ServiceContainer, windowManager: WindowManaging) {
self.serviceContainer = serviceContainer self.serviceContainer = serviceContainer
self.windowManager = windowManager self.windowManager = windowManager
@@ -40,16 +37,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
} }
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
// Set activation policy to hide dock icon
NSApplication.shared.setActivationPolicy(.accessory) NSApplication.shared.setActivationPolicy(.accessory)
logInfo("🚀 Application did finish launching")
timerEngine = serviceContainer.timerEngine timerEngine = serviceContainer.timerEngine
serviceContainer.setupSmartModeServices() serviceContainer.setupSmartModeServices()
// Initialize update manager after onboarding is complete // Initialize update manager after onboarding is complete
if settingsManager.settings.hasCompletedOnboarding { if settingsManager.settings.hasCompletedOnboarding {
updateManager = UpdateManager.shared updateManager = UpdateManager.shared
@@ -62,24 +55,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
if settingsManager.settings.hasCompletedOnboarding { if settingsManager.settings.hasCompletedOnboarding {
startTimers() 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 // Note: Smart mode setup is now handled by ServiceContainer
@@ -105,7 +80,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func onboardingCompleted() { func onboardingCompleted() {
startTimers() startTimers()
// Start update checks after onboarding
if updateManager == nil { if updateManager == nil {
updateManager = UpdateManager.shared 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() {}
} }

View File

@@ -41,7 +41,7 @@ struct GazeApp: App {
} }
.windowStyle(.hiddenTitleBar) .windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize) .windowResizability(.contentSize)
.defaultSize(width: 700, height: 700) .defaultSize(width: 1000, height: 700)
.commands { .commands {
CommandGroup(replacing: .newItem) {} CommandGroup(replacing: .newItem) {}
} }

View File

@@ -5,22 +5,22 @@
// Fullscreen overlay view for eye tracking calibration targets. // Fullscreen overlay view for eye tracking calibration targets.
// //
import SwiftUI
import Combine
import AVFoundation import AVFoundation
import Combine
import SwiftUI
struct CalibrationOverlayView: View { struct CalibrationOverlayView: View {
@StateObject private var calibrationManager = CalibrationManager.shared @StateObject private var calibrationManager = CalibrationManager.shared
@StateObject private var eyeTrackingService = EyeTrackingService.shared @StateObject private var eyeTrackingService = EyeTrackingService.shared
@StateObject private var viewModel = CalibrationOverlayViewModel() @StateObject private var viewModel = CalibrationOverlayViewModel()
let onDismiss: () -> Void let onDismiss: () -> Void
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack { ZStack {
Color.black.ignoresSafeArea() Color.black.ignoresSafeArea()
// Camera preview at 50% opacity (mirrored for natural feel) // Camera preview at 50% opacity (mirrored for natural feel)
if let previewLayer = eyeTrackingService.previewLayer { if let previewLayer = eyeTrackingService.previewLayer {
CameraPreviewView(previewLayer: previewLayer, borderColor: .clear) CameraPreviewView(previewLayer: previewLayer, borderColor: .clear)
@@ -28,14 +28,16 @@ struct CalibrationOverlayView: View {
.opacity(0.5) .opacity(0.5)
.ignoresSafeArea() .ignoresSafeArea()
} }
if let error = viewModel.showError { if let error = viewModel.showError {
errorView(error) errorView(error)
} else if !viewModel.cameraStarted { } else if !viewModel.cameraStarted {
startingCameraView startingCameraView
} else if calibrationManager.isCalibrating { } else if calibrationManager.isCalibrating {
calibrationContentView(screenSize: geometry.size) 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 // Only show completion if we started calibration this session AND it completed
completionView completionView
} else if viewModel.calibrationStarted { } else if viewModel.calibrationStarted {
@@ -45,10 +47,12 @@ struct CalibrationOverlayView: View {
} }
} }
.task { .task {
await viewModel.startCamera(eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) await viewModel.startCamera(
eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager)
} }
.onDisappear { .onDisappear {
viewModel.cleanup(eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) viewModel.cleanup(
eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager)
} }
.onChange(of: calibrationManager.currentStep) { oldStep, newStep in .onChange(of: calibrationManager.currentStep) { oldStep, newStep in
if newStep != nil && oldStep != newStep { if newStep != nil && oldStep != newStep {
@@ -56,38 +60,38 @@ struct CalibrationOverlayView: View {
} }
} }
} }
// MARK: - Starting Camera View // MARK: - Starting Camera View
private var startingCameraView: some View { private var startingCameraView: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
ProgressView() ProgressView()
.scaleEffect(2) .scaleEffect(2)
.tint(.white) .tint(.white)
Text("Starting camera...") Text("Starting camera...")
.font(.title2) .font(.title2)
.foregroundStyle(.white) .foregroundStyle(.white)
} }
} }
// MARK: - Error View // MARK: - Error View
private func errorView(_ message: String) -> some View { private func errorView(_ message: String) -> some View {
VStack(spacing: 20) { VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill") Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 60)) .font(.system(size: 60))
.foregroundStyle(.orange) .foregroundStyle(.orange)
Text("Camera Error") Text("Camera Error")
.font(.title) .font(.title)
.foregroundStyle(.white) .foregroundStyle(.white)
Text(message) Text(message)
.font(.body) .font(.body)
.foregroundStyle(.gray) .foregroundStyle(.gray)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Button("Close") { Button("Close") {
onDismiss() onDismiss()
} }
@@ -96,20 +100,20 @@ struct CalibrationOverlayView: View {
} }
.padding(40) .padding(40)
} }
// MARK: - Calibration Content // MARK: - Calibration Content
private func calibrationContentView(screenSize: CGSize) -> some View { private func calibrationContentView(screenSize: CGSize) -> some View {
ZStack { ZStack {
VStack { VStack {
progressBar progressBar
Spacer() Spacer()
} }
if let step = calibrationManager.currentStep { if let step = calibrationManager.currentStep {
calibrationTarget(for: step, screenSize: screenSize) calibrationTarget(for: step, screenSize: screenSize)
} }
VStack { VStack {
Spacer() Spacer()
HStack { HStack {
@@ -122,7 +126,7 @@ struct CalibrationOverlayView: View {
.padding(.horizontal, 40) .padding(.horizontal, 40)
.padding(.bottom, 40) .padding(.bottom, 40)
} }
// Face detection indicator // Face detection indicator
VStack { VStack {
HStack { HStack {
@@ -133,9 +137,9 @@ struct CalibrationOverlayView: View {
} }
} }
} }
// MARK: - Progress Bar // MARK: - Progress Bar
private var progressBar: some View { private var progressBar: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
HStack { HStack {
@@ -145,7 +149,7 @@ struct CalibrationOverlayView: View {
Text(calibrationManager.progressText) Text(calibrationManager.progressText)
.foregroundStyle(.white.opacity(0.7)) .foregroundStyle(.white.opacity(0.7))
} }
ProgressView(value: calibrationManager.progress) ProgressView(value: calibrationManager.progress)
.progressViewStyle(.linear) .progressViewStyle(.linear)
.tint(.blue) .tint(.blue)
@@ -153,15 +157,15 @@ struct CalibrationOverlayView: View {
.padding() .padding()
.background(Color.black.opacity(0.7)) .background(Color.black.opacity(0.7))
} }
// MARK: - Face Detection Indicator // MARK: - Face Detection Indicator
private var faceDetectionIndicator: some View { private var faceDetectionIndicator: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
Circle() Circle()
.fill(viewModel.stableFaceDetected ? Color.green : Color.red) .fill(viewModel.stableFaceDetected ? Color.green : Color.red)
.frame(width: 12, height: 12) .frame(width: 12, height: 12)
Text(viewModel.stableFaceDetected ? "Face detected" : "No face detected") Text(viewModel.stableFaceDetected ? "Face detected" : "No face detected")
.font(.caption) .font(.caption)
.foregroundStyle(.white.opacity(0.8)) .foregroundStyle(.white.opacity(0.8))
@@ -173,13 +177,13 @@ struct CalibrationOverlayView: View {
.padding() .padding()
.animation(.easeInOut(duration: 0.3), value: viewModel.stableFaceDetected) .animation(.easeInOut(duration: 0.3), value: viewModel.stableFaceDetected)
} }
// MARK: - Calibration Target // MARK: - Calibration Target
@ViewBuilder @ViewBuilder
private func calibrationTarget(for step: CalibrationStep, screenSize: CGSize) -> some View { private func calibrationTarget(for step: CalibrationStep, screenSize: CGSize) -> some View {
let position = targetPosition(for: step, screenSize: screenSize) let position = targetPosition(for: step, screenSize: screenSize)
VStack(spacing: 20) { VStack(spacing: 20) {
ZStack { ZStack {
// Outer ring (pulsing when counting down) // Outer ring (pulsing when counting down)
@@ -188,11 +192,11 @@ struct CalibrationOverlayView: View {
.frame(width: 100, height: 100) .frame(width: 100, height: 100)
.scaleEffect(viewModel.isCountingDown ? 1.2 : 1.0) .scaleEffect(viewModel.isCountingDown ? 1.2 : 1.0)
.animation( .animation(
viewModel.isCountingDown viewModel.isCountingDown
? .easeInOut(duration: 0.6).repeatForever(autoreverses: true) ? .easeInOut(duration: 0.6).repeatForever(autoreverses: true)
: .default, : .default,
value: viewModel.isCountingDown) value: viewModel.isCountingDown)
// Progress ring when collecting // Progress ring when collecting
if calibrationManager.isCollectingSamples { if calibrationManager.isCollectingSamples {
Circle() Circle()
@@ -200,15 +204,17 @@ struct CalibrationOverlayView: View {
.stroke(Color.green, lineWidth: 4) .stroke(Color.green, lineWidth: 4)
.frame(width: 90, height: 90) .frame(width: 90, height: 90)
.rotationEffect(.degrees(-90)) .rotationEffect(.degrees(-90))
.animation(.linear(duration: 0.1), value: calibrationManager.samplesCollected) .animation(
.linear(duration: 0.1), value: calibrationManager.samplesCollected)
} }
// Inner circle // Inner circle
Circle() Circle()
.fill(calibrationManager.isCollectingSamples ? Color.green : Color.blue) .fill(calibrationManager.isCollectingSamples ? Color.green : Color.blue)
.frame(width: 60, height: 60) .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 // Countdown number or collecting indicator
if viewModel.isCountingDown && viewModel.countdownValue > 0 { if viewModel.isCountingDown && viewModel.countdownValue > 0 {
Text("\(viewModel.countdownValue)") Text("\(viewModel.countdownValue)")
@@ -220,7 +226,7 @@ struct CalibrationOverlayView: View {
.foregroundStyle(.white) .foregroundStyle(.white)
} }
} }
Text(instructionText(for: step)) Text(instructionText(for: step))
.font(.title2) .font(.title2)
.foregroundStyle(.white) .foregroundStyle(.white)
@@ -231,7 +237,7 @@ struct CalibrationOverlayView: View {
} }
.position(position) .position(position)
} }
private func instructionText(for step: CalibrationStep) -> String { private func instructionText(for step: CalibrationStep) -> String {
if viewModel.isCountingDown && viewModel.countdownValue > 0 { if viewModel.isCountingDown && viewModel.countdownValue > 0 {
return "Get ready..." return "Get ready..."
@@ -241,9 +247,9 @@ struct CalibrationOverlayView: View {
return step.instructionText return step.instructionText
} }
} }
// MARK: - Buttons // MARK: - Buttons
private var skipButton: some View { private var skipButton: some View {
Button { Button {
viewModel.skipCurrentStep(calibrationManager: calibrationManager) viewModel.skipCurrentStep(calibrationManager: calibrationManager)
@@ -257,10 +263,11 @@ struct CalibrationOverlayView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
private var cancelButton: some View { private var cancelButton: some View {
Button { Button {
viewModel.cleanup(eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager) viewModel.cleanup(
eyeTrackingService: eyeTrackingService, calibrationManager: calibrationManager)
onDismiss() onDismiss()
} label: { } label: {
HStack(spacing: 6) { HStack(spacing: 6) {
@@ -276,24 +283,24 @@ struct CalibrationOverlayView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
.keyboardShortcut(.escape, modifiers: []) .keyboardShortcut(.escape, modifiers: [])
} }
// MARK: - Completion View // MARK: - Completion View
private var completionView: some View { private var completionView: some View {
VStack(spacing: 30) { VStack(spacing: 30) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80)) .font(.system(size: 80))
.foregroundStyle(.green) .foregroundStyle(.green)
Text("Calibration Complete!") Text("Calibration Complete!")
.font(.largeTitle) .font(.largeTitle)
.foregroundStyle(.white) .foregroundStyle(.white)
.fontWeight(.bold) .fontWeight(.bold)
Text("Your eye tracking has been calibrated successfully.") Text("Your eye tracking has been calibrated successfully.")
.font(.title3) .font(.title3)
.foregroundStyle(.gray) .foregroundStyle(.gray)
Button("Done") { Button("Done") {
onDismiss() onDismiss()
} }
@@ -307,18 +314,18 @@ struct CalibrationOverlayView: View {
} }
} }
} }
// MARK: - Helper Methods // MARK: - Helper Methods
private func targetPosition(for step: CalibrationStep, screenSize: CGSize) -> CGPoint { private func targetPosition(for step: CalibrationStep, screenSize: CGSize) -> CGPoint {
let width = screenSize.width let width = screenSize.width
let height = screenSize.height let height = screenSize.height
let centerX = width / 2 let centerX = width / 2
let centerY = height / 2 let centerY = height / 2
let marginX: CGFloat = 150 let marginX: CGFloat = 150
let marginY: CGFloat = 120 let marginY: CGFloat = 120
switch step { switch step {
case .center: case .center:
return CGPoint(x: centerX, y: centerY) return CGPoint(x: centerX, y: centerY)
@@ -356,23 +363,24 @@ class CalibrationOverlayViewModel: ObservableObject {
@Published var showError: String? @Published var showError: String?
@Published var calibrationStarted = false @Published var calibrationStarted = false
@Published var stableFaceDetected = false // Debounced face detection @Published var stableFaceDetected = false // Debounced face detection
private var countdownTask: Task<Void, Never>? private var countdownTask: Task<Void, Never>?
private var faceDetectionCancellable: AnyCancellable? private var faceDetectionCancellable: AnyCancellable?
private var lastFaceDetectedTime: Date = .distantPast private var lastFaceDetectedTime: Date = .distantPast
private let faceDetectionDebounce: TimeInterval = 0.5 // 500ms debounce private let faceDetectionDebounce: TimeInterval = 0.5 // 500ms debounce
func startCamera(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager) async { func startCamera(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager)
async
{
do { do {
try await eyeTrackingService.startEyeTracking() try await eyeTrackingService.startEyeTracking()
cameraStarted = true cameraStarted = true
// Set up debounced face detection // Set up debounced face detection
setupFaceDetectionObserver(eyeTrackingService: eyeTrackingService) setupFaceDetectionObserver(eyeTrackingService: eyeTrackingService)
// Small delay to let camera stabilize try? await Task.sleep(for: .seconds(0.5))
try? await Task.sleep(nanoseconds: 500_000_000)
// Reset any previous calibration data before starting fresh // Reset any previous calibration data before starting fresh
calibrationManager.resetForNewCalibration() calibrationManager.resetForNewCalibration()
calibrationManager.startCalibration() calibrationManager.startCalibration()
@@ -382,13 +390,13 @@ class CalibrationOverlayViewModel: ObservableObject {
showError = "Failed to start camera: \(error.localizedDescription)" showError = "Failed to start camera: \(error.localizedDescription)"
} }
} }
private func setupFaceDetectionObserver(eyeTrackingService: EyeTrackingService) { private func setupFaceDetectionObserver(eyeTrackingService: EyeTrackingService) {
faceDetectionCancellable = eyeTrackingService.$faceDetected faceDetectionCancellable = eyeTrackingService.$faceDetected
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] detected in .sink { [weak self] detected in
guard let self = self else { return } guard let self = self else { return }
if detected { if detected {
// Face detected - update immediately // Face detected - update immediately
self.lastFaceDetectedTime = Date() self.lastFaceDetectedTime = Date()
@@ -402,39 +410,39 @@ class CalibrationOverlayViewModel: ObservableObject {
} }
} }
} }
func cleanup(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager) { func cleanup(eyeTrackingService: EyeTrackingService, calibrationManager: CalibrationManager) {
countdownTask?.cancel() countdownTask?.cancel()
countdownTask = nil countdownTask = nil
faceDetectionCancellable?.cancel() faceDetectionCancellable?.cancel()
faceDetectionCancellable = nil faceDetectionCancellable = nil
isCountingDown = false isCountingDown = false
if calibrationManager.isCalibrating { if calibrationManager.isCalibrating {
calibrationManager.cancelCalibration() calibrationManager.cancelCalibration()
} }
eyeTrackingService.stopEyeTracking() eyeTrackingService.stopEyeTracking()
} }
func skipCurrentStep(calibrationManager: CalibrationManager) { func skipCurrentStep(calibrationManager: CalibrationManager) {
countdownTask?.cancel() countdownTask?.cancel()
countdownTask = nil countdownTask = nil
isCountingDown = false isCountingDown = false
calibrationManager.skipStep() calibrationManager.skipStep()
} }
func startStepCountdown(calibrationManager: CalibrationManager) { func startStepCountdown(calibrationManager: CalibrationManager) {
countdownTask?.cancel() countdownTask?.cancel()
countdownTask = nil countdownTask = nil
countdownValue = 1 countdownValue = 1
isCountingDown = true isCountingDown = true
countdownTask = Task { @MainActor in countdownTask = Task { @MainActor in
// Just 1 second countdown // Just 1 second countdown
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(1))
if Task.isCancelled { return } if Task.isCancelled { return }
// Done counting, start collecting // Done counting, start collecting
isCountingDown = false isCountingDown = false
countdownValue = 0 countdownValue = 0
@@ -446,4 +454,3 @@ class CalibrationOverlayViewModel: ObservableObject {
#Preview { #Preview {
CalibrationOverlayView(onDismiss: {}) CalibrationOverlayView(onDismiss: {})
} }

View File

@@ -47,7 +47,7 @@ final class OnboardingWindowPresenter {
windowController = nil windowController = nil
return false return false
} }
DispatchQueue.main.async { DispatchQueue.main.async {
NSApp.unhide(nil) NSApp.unhide(nil)
NSApp.activate(ignoringOtherApps: true) NSApp.activate(ignoringOtherApps: true)
@@ -83,7 +83,9 @@ final class OnboardingWindowPresenter {
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
window.center() window.center()
window.isReleasedWhenClosed = true window.isReleasedWhenClosed = true
window.collectionBehavior = [.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary] window.collectionBehavior = [
.managed, .participatesInCycle, .moveToActiveSpace, .fullScreenAuxiliary,
]
window.contentView = NSHostingView( window.contentView = NSHostingView(
rootView: OnboardingContainerView(settingsManager: settingsManager) rootView: OnboardingContainerView(settingsManager: settingsManager)

View File

@@ -6,92 +6,89 @@
// //
import XCTest import XCTest
@testable import Gaze @testable import Gaze
@MainActor @MainActor
final class AppDelegateTestabilityTests: XCTestCase { final class AppDelegateTestabilityTests: XCTestCase {
var testEnv: TestEnvironment! var testEnv: TestEnvironment!
override func setUp() async throws { override func setUp() async throws {
testEnv = TestEnvironment(settings: .onboardingCompleted) testEnv = TestEnvironment(settings: .onboardingCompleted)
} }
override func tearDown() async throws { override func tearDown() async throws {
testEnv = nil testEnv = nil
} }
func testAppDelegateCreationWithMocks() { func testAppDelegateCreationWithMocks() {
let appDelegate = testEnv.createAppDelegate() let appDelegate = testEnv.createAppDelegate()
XCTAssertNotNil(appDelegate) XCTAssertNotNil(appDelegate)
} }
func testWindowManagerReceivesReminderEvents() async throws { func testWindowManagerReceivesReminderEvents() async throws {
let appDelegate = testEnv.createAppDelegate() let appDelegate = testEnv.createAppDelegate()
// Simulate app launch
let notification = Notification(name: NSApplication.didFinishLaunchingNotification) let notification = Notification(name: NSApplication.didFinishLaunchingNotification)
appDelegate.applicationDidFinishLaunching(notification) appDelegate.applicationDidFinishLaunching(notification)
// Give time for setup try await Task.sleep(for: .milliseconds(100))
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
// Trigger a reminder through timer engine
if let timerEngine = appDelegate.timerEngine { if let timerEngine = appDelegate.timerEngine {
let timerId = TimerIdentifier.builtIn(.blink) let timerId = TimerIdentifier.builtIn(.blink)
timerEngine.triggerReminder(for: timerId) timerEngine.triggerReminder(for: timerId)
// Give time for reminder to propagate try await Task.sleep(for: .milliseconds(100))
try await Task.sleep(nanoseconds: 100_000_000) // 100ms
// Verify window manager received the show command // Verify window manager received the show command
XCTAssertTrue(testEnv.windowManager.didPerformOperation(.showSubtleReminder)) XCTAssertTrue(testEnv.windowManager.didPerformOperation(.showSubtleReminder))
} else { } else {
XCTFail("TimerEngine not initialized") XCTFail("TimerEngine not initialized")
} }
} }
func testSettingsChangesPropagate() async throws { func testSettingsChangesPropagate() async throws {
let appDelegate = testEnv.createAppDelegate() let appDelegate = testEnv.createAppDelegate()
// Change a setting // Change a setting
testEnv.settingsManager.settings.lookAwayTimer.enabled = false testEnv.settingsManager.settings.lookAwayTimer.enabled = false
// Give time for observation try await Task.sleep(for: .milliseconds(50))
try await Task.sleep(nanoseconds: 50_000_000) // 50ms
// Verify the change propagated // Verify the change propagated
XCTAssertFalse(testEnv.settingsManager.settings.lookAwayTimer.enabled) XCTAssertFalse(testEnv.settingsManager.settings.lookAwayTimer.enabled)
} }
func testOpenSettingsUsesWindowManager() { func testOpenSettingsUsesWindowManager() {
let appDelegate = testEnv.createAppDelegate() let appDelegate = testEnv.createAppDelegate()
appDelegate.openSettings(tab: 2) appDelegate.openSettings(tab: 2)
// Give time for async dispatch // Give time for async dispatch
let expectation = XCTestExpectation(description: "Settings opened") let expectation = XCTestExpectation(description: "Settings opened")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { 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() expectation.fulfill()
} }
wait(for: [expectation], timeout: 1.0) wait(for: [expectation], timeout: 1.0)
} }
func testOpenOnboardingUsesWindowManager() { func testOpenOnboardingUsesWindowManager() {
let appDelegate = testEnv.createAppDelegate() let appDelegate = testEnv.createAppDelegate()
appDelegate.openOnboarding() appDelegate.openOnboarding()
// Give time for async dispatch // Give time for async dispatch
let expectation = XCTestExpectation(description: "Onboarding opened") let expectation = XCTestExpectation(description: "Onboarding opened")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
XCTAssertTrue(self.testEnv.windowManager.didPerformOperation(.showOnboarding)) XCTAssertTrue(self.testEnv.windowManager.didPerformOperation(.showOnboarding))
expectation.fulfill() expectation.fulfill()
} }
wait(for: [expectation], timeout: 1.0) wait(for: [expectation], timeout: 1.0)
} }
} }

View File

@@ -7,36 +7,37 @@
import Combine import Combine
import XCTest import XCTest
@testable import Gaze @testable import Gaze
@MainActor @MainActor
final class TimerEngineTests: XCTestCase { final class TimerEngineTests: XCTestCase {
var testEnv: TestEnvironment! var testEnv: TestEnvironment!
var timerEngine: TimerEngine! var timerEngine: TimerEngine!
var cancellables: Set<AnyCancellable>! var cancellables: Set<AnyCancellable>!
override func setUp() async throws { override func setUp() async throws {
testEnv = TestEnvironment(settings: .defaults) testEnv = TestEnvironment(settings: .defaults)
timerEngine = testEnv.container.timerEngine timerEngine = testEnv.container.timerEngine
cancellables = [] cancellables = []
} }
override func tearDown() async throws { override func tearDown() async throws {
timerEngine?.stop() timerEngine?.stop()
cancellables = nil cancellables = nil
timerEngine = nil timerEngine = nil
testEnv = nil testEnv = nil
} }
// MARK: - Initialization Tests // MARK: - Initialization Tests
func testTimerEngineInitialization() { func testTimerEngineInitialization() {
XCTAssertNotNil(timerEngine) XCTAssertNotNil(timerEngine)
XCTAssertEqual(timerEngine.timerStates.count, 0) XCTAssertEqual(timerEngine.timerStates.count, 0)
XCTAssertNil(timerEngine.activeReminder) XCTAssertNil(timerEngine.activeReminder)
} }
func testTimerEngineWithCustomTimeProvider() { func testTimerEngineWithCustomTimeProvider() {
let timeProvider = MockTimeProvider() let timeProvider = MockTimeProvider()
let engine = TimerEngine( let engine = TimerEngine(
@@ -44,174 +45,173 @@ final class TimerEngineTests: XCTestCase {
enforceModeService: nil, enforceModeService: nil,
timeProvider: timeProvider timeProvider: timeProvider
) )
XCTAssertNotNil(engine) XCTAssertNotNil(engine)
} }
// MARK: - Start/Stop Tests // MARK: - Start/Stop Tests
func testStartTimers() { func testStartTimers() {
timerEngine.start() timerEngine.start()
// Should create timer states for enabled timers // Should create timer states for enabled timers
XCTAssertGreaterThan(timerEngine.timerStates.count, 0) XCTAssertGreaterThan(timerEngine.timerStates.count, 0)
} }
func testStopTimers() { func testStopTimers() {
timerEngine.start() timerEngine.start()
let initialCount = timerEngine.timerStates.count let initialCount = timerEngine.timerStates.count
XCTAssertGreaterThan(initialCount, 0) XCTAssertGreaterThan(initialCount, 0)
timerEngine.stop() timerEngine.stop()
// Timers should be cleared // Timers should be cleared
XCTAssertEqual(timerEngine.timerStates.count, 0) XCTAssertEqual(timerEngine.timerStates.count, 0)
} }
func testRestartTimers() { func testRestartTimers() {
timerEngine.start() timerEngine.start()
let firstCount = timerEngine.timerStates.count let firstCount = timerEngine.timerStates.count
timerEngine.stop() timerEngine.stop()
XCTAssertEqual(timerEngine.timerStates.count, 0) XCTAssertEqual(timerEngine.timerStates.count, 0)
timerEngine.start() timerEngine.start()
let secondCount = timerEngine.timerStates.count let secondCount = timerEngine.timerStates.count
XCTAssertEqual(firstCount, secondCount) XCTAssertEqual(firstCount, secondCount)
} }
// MARK: - Pause/Resume Tests // MARK: - Pause/Resume Tests
func testPauseAllTimers() { func testPauseAllTimers() {
timerEngine.start() timerEngine.start()
timerEngine.pause() timerEngine.pause()
for (_, state) in timerEngine.timerStates { for (_, state) in timerEngine.timerStates {
XCTAssertTrue(state.isPaused) XCTAssertTrue(state.isPaused)
} }
} }
func testResumeAllTimers() { func testResumeAllTimers() {
timerEngine.start() timerEngine.start()
timerEngine.pause() timerEngine.pause()
timerEngine.resume() timerEngine.resume()
for (_, state) in timerEngine.timerStates { for (_, state) in timerEngine.timerStates {
XCTAssertFalse(state.isPaused) XCTAssertFalse(state.isPaused)
} }
} }
func testPauseSpecificTimer() { func testPauseSpecificTimer() {
timerEngine.start() timerEngine.start()
guard let firstTimer = timerEngine.timerStates.keys.first else { guard let firstTimer = timerEngine.timerStates.keys.first else {
XCTFail("No timers available") XCTFail("No timers available")
return return
} }
timerEngine.pauseTimer(identifier: firstTimer) timerEngine.pauseTimer(identifier: firstTimer)
let state = timerEngine.timerStates[firstTimer] let state = timerEngine.timerStates[firstTimer]
XCTAssertTrue(state?.isPaused ?? false) XCTAssertTrue(state?.isPaused ?? false)
} }
func testResumeSpecificTimer() { func testResumeSpecificTimer() {
timerEngine.start() timerEngine.start()
guard let firstTimer = timerEngine.timerStates.keys.first else { guard let firstTimer = timerEngine.timerStates.keys.first else {
XCTFail("No timers available") XCTFail("No timers available")
return return
} }
timerEngine.pauseTimer(identifier: firstTimer) timerEngine.pauseTimer(identifier: firstTimer)
XCTAssertTrue(timerEngine.isTimerPaused(firstTimer)) XCTAssertTrue(timerEngine.isTimerPaused(firstTimer))
timerEngine.resumeTimer(identifier: firstTimer) timerEngine.resumeTimer(identifier: firstTimer)
XCTAssertFalse(timerEngine.isTimerPaused(firstTimer)) XCTAssertFalse(timerEngine.isTimerPaused(firstTimer))
} }
// MARK: - Skip Tests // MARK: - Skip Tests
func testSkipNext() { func testSkipNext() {
timerEngine.start() timerEngine.start()
guard let firstTimer = timerEngine.timerStates.keys.first else { guard let firstTimer = timerEngine.timerStates.keys.first else {
XCTFail("No timers available") XCTFail("No timers available")
return return
} }
timerEngine.skipNext(identifier: firstTimer) timerEngine.skipNext(identifier: firstTimer)
// Timer should be reset // Timer should be reset
XCTAssertNotNil(timerEngine.timerStates[firstTimer]) XCTAssertNotNil(timerEngine.timerStates[firstTimer])
} }
// MARK: - Reminder Tests // MARK: - Reminder Tests
func testTriggerReminder() async throws { func testTriggerReminder() async throws {
timerEngine.start() timerEngine.start()
guard let firstTimer = timerEngine.timerStates.keys.first else { guard let firstTimer = timerEngine.timerStates.keys.first else {
XCTFail("No timers available") XCTFail("No timers available")
return return
} }
timerEngine.triggerReminder(for: firstTimer) timerEngine.triggerReminder(for: firstTimer)
// Give time for async operations try await Task.sleep(for: .milliseconds(50))
try await Task.sleep(nanoseconds: 50_000_000)
XCTAssertNotNil(timerEngine.activeReminder) XCTAssertNotNil(timerEngine.activeReminder)
} }
func testDismissReminder() { func testDismissReminder() {
timerEngine.start() timerEngine.start()
guard let firstTimer = timerEngine.timerStates.keys.first else { guard let firstTimer = timerEngine.timerStates.keys.first else {
XCTFail("No timers available") XCTFail("No timers available")
return return
} }
timerEngine.triggerReminder(for: firstTimer) timerEngine.triggerReminder(for: firstTimer)
XCTAssertNotNil(timerEngine.activeReminder) XCTAssertNotNil(timerEngine.activeReminder)
timerEngine.dismissReminder() timerEngine.dismissReminder()
XCTAssertNil(timerEngine.activeReminder) XCTAssertNil(timerEngine.activeReminder)
} }
// MARK: - Time Remaining Tests // MARK: - Time Remaining Tests
func testGetTimeRemaining() { func testGetTimeRemaining() {
timerEngine.start() timerEngine.start()
guard let firstTimer = timerEngine.timerStates.keys.first else { guard let firstTimer = timerEngine.timerStates.keys.first else {
XCTFail("No timers available") XCTFail("No timers available")
return return
} }
let remaining = timerEngine.getTimeRemaining(for: firstTimer) let remaining = timerEngine.getTimeRemaining(for: firstTimer)
XCTAssertGreaterThan(remaining, 0) XCTAssertGreaterThan(remaining, 0)
} }
func testGetFormattedTimeRemaining() { func testGetFormattedTimeRemaining() {
timerEngine.start() timerEngine.start()
guard let firstTimer = timerEngine.timerStates.keys.first else { guard let firstTimer = timerEngine.timerStates.keys.first else {
XCTFail("No timers available") XCTFail("No timers available")
return return
} }
let formatted = timerEngine.getFormattedTimeRemaining(for: firstTimer) let formatted = timerEngine.getFormattedTimeRemaining(for: firstTimer)
XCTAssertFalse(formatted.isEmpty) XCTAssertFalse(formatted.isEmpty)
XCTAssertTrue(formatted.contains(":")) XCTAssertTrue(formatted.contains(":"))
} }
// MARK: - Timer State Publisher Tests // MARK: - Timer State Publisher Tests
func testTimerStatesPublisher() async throws { func testTimerStatesPublisher() async throws {
let expectation = XCTestExpectation(description: "Timer states changed") let expectation = XCTestExpectation(description: "Timer states changed")
timerEngine.$timerStates timerEngine.$timerStates
.dropFirst() .dropFirst()
.sink { states in .sink { states in
@@ -220,15 +220,15 @@ final class TimerEngineTests: XCTestCase {
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
timerEngine.start() timerEngine.start()
await fulfillment(of: [expectation], timeout: 1.0) await fulfillment(of: [expectation], timeout: 1.0)
} }
func testActiveReminderPublisher() async throws { func testActiveReminderPublisher() async throws {
let expectation = XCTestExpectation(description: "Active reminder changed") let expectation = XCTestExpectation(description: "Active reminder changed")
timerEngine.$activeReminder timerEngine.$activeReminder
.dropFirst() .dropFirst()
.sink { reminder in .sink { reminder in
@@ -237,67 +237,67 @@ final class TimerEngineTests: XCTestCase {
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
timerEngine.start() timerEngine.start()
guard let firstTimer = timerEngine.timerStates.keys.first else { guard let firstTimer = timerEngine.timerStates.keys.first else {
XCTFail("No timers available") XCTFail("No timers available")
return return
} }
timerEngine.triggerReminder(for: firstTimer) timerEngine.triggerReminder(for: firstTimer)
await fulfillment(of: [expectation], timeout: 1.0) await fulfillment(of: [expectation], timeout: 1.0)
} }
// MARK: - System Sleep/Wake Tests // MARK: - System Sleep/Wake Tests
func testHandleSystemSleep() { func testHandleSystemSleep() {
timerEngine.start() timerEngine.start()
let statesBefore = timerEngine.timerStates.count let statesBefore = timerEngine.timerStates.count
timerEngine.handleSystemSleep() timerEngine.handleSystemSleep()
// States should still exist // States should still exist
XCTAssertEqual(timerEngine.timerStates.count, statesBefore) XCTAssertEqual(timerEngine.timerStates.count, statesBefore)
} }
func testHandleSystemWake() { func testHandleSystemWake() {
timerEngine.start() timerEngine.start()
timerEngine.handleSystemSleep() timerEngine.handleSystemSleep()
timerEngine.handleSystemWake() timerEngine.handleSystemWake()
// Should handle wake event without crashing // Should handle wake event without crashing
XCTAssertGreaterThan(timerEngine.timerStates.count, 0) XCTAssertGreaterThan(timerEngine.timerStates.count, 0)
} }
// MARK: - Disabled Timer Tests // MARK: - Disabled Timer Tests
func testDisabledTimersNotInitialized() { func testDisabledTimersNotInitialized() {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = false settings.lookAwayTimer.enabled = false
settings.blinkTimer.enabled = false settings.blinkTimer.enabled = false
settings.postureTimer.enabled = false settings.postureTimer.enabled = false
let settingsManager = EnhancedMockSettingsManager(settings: settings) let settingsManager = EnhancedMockSettingsManager(settings: settings)
let engine = TimerEngine(settingsManager: settingsManager) let engine = TimerEngine(settingsManager: settingsManager)
engine.start() engine.start()
XCTAssertEqual(engine.timerStates.count, 0) XCTAssertEqual(engine.timerStates.count, 0)
} }
func testPartiallyEnabledTimers() { func testPartiallyEnabledTimers() {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = true settings.lookAwayTimer.enabled = true
settings.blinkTimer.enabled = false settings.blinkTimer.enabled = false
settings.postureTimer.enabled = false settings.postureTimer.enabled = false
let settingsManager = EnhancedMockSettingsManager(settings: settings) let settingsManager = EnhancedMockSettingsManager(settings: settings)
let engine = TimerEngine(settingsManager: settingsManager) let engine = TimerEngine(settingsManager: settingsManager)
engine.start() engine.start()
XCTAssertEqual(engine.timerStates.count, 1) XCTAssertEqual(engine.timerStates.count, 1)
} }
} }

View File

@@ -5,8 +5,11 @@
// Test helpers and utilities for unit testing. // Test helpers and utilities for unit testing.
// //
// MARK: - Import Statement for Combine
import Combine
import Foundation import Foundation
import XCTest import XCTest
@testable import Gaze @testable import Gaze
// MARK: - Enhanced MockSettingsManager // MARK: - Enhanced MockSettingsManager
@@ -16,21 +19,22 @@ import XCTest
@Observable @Observable
final class EnhancedMockSettingsManager: SettingsProviding { final class EnhancedMockSettingsManager: SettingsProviding {
var settings: AppSettings var settings: AppSettings
@ObservationIgnored @ObservationIgnored
private let _settingsSubject: CurrentValueSubject<AppSettings, Never> private let _settingsSubject: CurrentValueSubject<AppSettings, Never>
var settingsPublisher: AnyPublisher<AppSettings, Never> { var settingsPublisher: AnyPublisher<AppSettings, Never> {
_settingsSubject.eraseToAnyPublisher() _settingsSubject.eraseToAnyPublisher()
} }
@ObservationIgnored @ObservationIgnored
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [ private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] =
.lookAway: \.lookAwayTimer, [
.blink: \.blinkTimer, .lookAway: \.lookAwayTimer,
.posture: \.postureTimer, .blink: \.blinkTimer,
] .posture: \.postureTimer,
]
// Track method calls for verification // Track method calls for verification
@ObservationIgnored @ObservationIgnored
private(set) var saveCallCount = 0 private(set) var saveCallCount = 0
@@ -40,19 +44,19 @@ final class EnhancedMockSettingsManager: SettingsProviding {
private(set) var loadCallCount = 0 private(set) var loadCallCount = 0
@ObservationIgnored @ObservationIgnored
private(set) var resetToDefaultsCallCount = 0 private(set) var resetToDefaultsCallCount = 0
init(settings: AppSettings = .defaults) { init(settings: AppSettings = .defaults) {
self.settings = settings self.settings = settings
self._settingsSubject = CurrentValueSubject(settings) self._settingsSubject = CurrentValueSubject(settings)
} }
func timerConfiguration(for type: TimerType) -> TimerConfiguration { func timerConfiguration(for type: TimerType) -> TimerConfiguration {
guard let keyPath = timerConfigKeyPaths[type] else { guard let keyPath = timerConfigKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)") preconditionFailure("Unknown timer type: \(type)")
} }
return settings[keyPath: keyPath] return settings[keyPath: keyPath]
} }
func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) {
guard let keyPath = timerConfigKeyPaths[type] else { guard let keyPath = timerConfigKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)") preconditionFailure("Unknown timer type: \(type)")
@@ -60,7 +64,7 @@ final class EnhancedMockSettingsManager: SettingsProviding {
settings[keyPath: keyPath] = configuration settings[keyPath: keyPath] = configuration
_settingsSubject.send(settings) _settingsSubject.send(settings)
} }
func allTimerConfigurations() -> [TimerType: TimerConfiguration] { func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
var configs: [TimerType: TimerConfiguration] = [:] var configs: [TimerType: TimerConfiguration] = [:]
for (type, keyPath) in timerConfigKeyPaths { for (type, keyPath) in timerConfigKeyPaths {
@@ -68,27 +72,27 @@ final class EnhancedMockSettingsManager: SettingsProviding {
} }
return configs return configs
} }
func save() { func save() {
saveCallCount += 1 saveCallCount += 1
_settingsSubject.send(settings) _settingsSubject.send(settings)
} }
func saveImmediately() { func saveImmediately() {
saveImmediatelyCallCount += 1 saveImmediatelyCallCount += 1
_settingsSubject.send(settings) _settingsSubject.send(settings)
} }
func load() { func load() {
loadCallCount += 1 loadCallCount += 1
} }
func resetToDefaults() { func resetToDefaults() {
resetToDefaultsCallCount += 1 resetToDefaultsCallCount += 1
settings = .defaults settings = .defaults
_settingsSubject.send(settings) _settingsSubject.send(settings)
} }
// Test helpers // Test helpers
func reset() { func reset() {
saveCallCount = 0 saveCallCount = 0
@@ -105,17 +109,17 @@ final class EnhancedMockSettingsManager: SettingsProviding {
@MainActor @MainActor
final class MockFullscreenDetectionService: ObservableObject, FullscreenDetectionProviding { final class MockFullscreenDetectionService: ObservableObject, FullscreenDetectionProviding {
@Published var isFullscreenActive: Bool = false @Published var isFullscreenActive: Bool = false
var isFullscreenActivePublisher: Published<Bool>.Publisher { var isFullscreenActivePublisher: Published<Bool>.Publisher {
$isFullscreenActive $isFullscreenActive
} }
private(set) var forceUpdateCallCount = 0 private(set) var forceUpdateCallCount = 0
func forceUpdate() { func forceUpdate() {
forceUpdateCallCount += 1 forceUpdateCallCount += 1
} }
func simulateFullscreen(_ active: Bool) { func simulateFullscreen(_ active: Bool) {
isFullscreenActive = active isFullscreenActive = active
} }
@@ -124,22 +128,22 @@ final class MockFullscreenDetectionService: ObservableObject, FullscreenDetectio
@MainActor @MainActor
final class MockIdleMonitoringService: ObservableObject, IdleMonitoringProviding { final class MockIdleMonitoringService: ObservableObject, IdleMonitoringProviding {
@Published var isIdle: Bool = false @Published var isIdle: Bool = false
var isIdlePublisher: Published<Bool>.Publisher { var isIdlePublisher: Published<Bool>.Publisher {
$isIdle $isIdle
} }
private(set) var thresholdMinutes: Int = 5 private(set) var thresholdMinutes: Int = 5
private(set) var forceUpdateCallCount = 0 private(set) var forceUpdateCallCount = 0
func updateThreshold(minutes: Int) { func updateThreshold(minutes: Int) {
thresholdMinutes = minutes thresholdMinutes = minutes
} }
func forceUpdate() { func forceUpdate() {
forceUpdateCallCount += 1 forceUpdateCallCount += 1
} }
func simulateIdle(_ idle: Bool) { func simulateIdle(_ idle: Bool) {
isIdle = idle isIdle = idle
} }
@@ -156,7 +160,7 @@ extension AppSettings {
settings.postureTimer.enabled = false settings.postureTimer.enabled = false
return settings return settings
} }
/// Settings with only lookAway timer enabled /// Settings with only lookAway timer enabled
static var onlyLookAwayEnabled: AppSettings { static var onlyLookAwayEnabled: AppSettings {
var settings = AppSettings.defaults var settings = AppSettings.defaults
@@ -165,7 +169,7 @@ extension AppSettings {
settings.postureTimer.enabled = false settings.postureTimer.enabled = false
return settings return settings
} }
/// Settings with short intervals for testing /// Settings with short intervals for testing
static var shortIntervals: AppSettings { static var shortIntervals: AppSettings {
var settings = AppSettings.defaults var settings = AppSettings.defaults
@@ -174,14 +178,14 @@ extension AppSettings {
settings.postureTimer.intervalSeconds = 7 settings.postureTimer.intervalSeconds = 7
return settings return settings
} }
/// Settings with onboarding completed /// Settings with onboarding completed
static var onboardingCompleted: AppSettings { static var onboardingCompleted: AppSettings {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.hasCompletedOnboarding = true settings.hasCompletedOnboarding = true
return settings return settings
} }
/// Settings with smart mode fully enabled /// Settings with smart mode fully enabled
static var smartModeEnabled: AppSettings { static var smartModeEnabled: AppSettings {
var settings = AppSettings.defaults var settings = AppSettings.defaults
@@ -209,19 +213,19 @@ struct TestEnvironment {
let windowManager: MockWindowManager let windowManager: MockWindowManager
let settingsManager: EnhancedMockSettingsManager let settingsManager: EnhancedMockSettingsManager
let timeProvider: MockTimeProvider let timeProvider: MockTimeProvider
init(settings: AppSettings = .defaults) { init(settings: AppSettings = .defaults) {
self.settingsManager = EnhancedMockSettingsManager(settings: settings) self.settingsManager = EnhancedMockSettingsManager(settings: settings)
self.container = ServiceContainer(settingsManager: settingsManager) self.container = ServiceContainer(settingsManager: settingsManager)
self.windowManager = MockWindowManager() self.windowManager = MockWindowManager()
self.timeProvider = MockTimeProvider() self.timeProvider = MockTimeProvider()
} }
/// Creates an AppDelegate with all test dependencies /// Creates an AppDelegate with all test dependencies
func createAppDelegate() -> AppDelegate { func createAppDelegate() -> AppDelegate {
return AppDelegate(serviceContainer: container, windowManager: windowManager) return AppDelegate(serviceContainer: container, windowManager: windowManager)
} }
/// Resets all mock state /// Resets all mock state
func reset() { func reset() {
windowManager.reset() windowManager.reset()
@@ -245,10 +249,10 @@ extension XCTestCase {
XCTFail(message) XCTFail(message)
return return
} }
try await Task.sleep(nanoseconds: 10_000_000) // 10ms try await Task.sleep(for: .milliseconds(100))
} }
} }
/// Waits for a published value to change /// Waits for a published value to change
@MainActor @MainActor
func waitForPublisher<T: Equatable>( func waitForPublisher<T: Equatable>(
@@ -258,17 +262,14 @@ extension XCTestCase {
) async throws { ) async throws {
let expectation = XCTestExpectation(description: "Publisher value changed") let expectation = XCTestExpectation(description: "Publisher value changed")
var cancellable: AnyCancellable? var cancellable: AnyCancellable?
cancellable = publisher.sink { value in cancellable = publisher.sink { value in
if value == expectedValue { if value == expectedValue {
expectation.fulfill() expectation.fulfill()
} }
} }
await fulfillment(of: [expectation], timeout: timeout) await fulfillment(of: [expectation], timeout: timeout)
cancellable?.cancel() cancellable?.cancel()
} }
} }
// MARK: - Import Statement for Combine
import Combine

View File

@@ -7,24 +7,25 @@
import Combine import Combine
import XCTest import XCTest
@testable import Gaze @testable import Gaze
@MainActor @MainActor
final class TimerEngineTestabilityTests: XCTestCase { final class TimerEngineTestabilityTests: XCTestCase {
var testEnv: TestEnvironment! var testEnv: TestEnvironment!
var cancellables: Set<AnyCancellable>! var cancellables: Set<AnyCancellable>!
override func setUp() async throws { override func setUp() async throws {
testEnv = TestEnvironment(settings: .shortIntervals) testEnv = TestEnvironment(settings: .shortIntervals)
cancellables = [] cancellables = []
} }
override func tearDown() async throws { override func tearDown() async throws {
cancellables = nil cancellables = nil
testEnv = nil testEnv = nil
} }
func testTimerEngineCreationWithMocks() { func testTimerEngineCreationWithMocks() {
let timeProvider = MockTimeProvider() let timeProvider = MockTimeProvider()
let timerEngine = TimerEngine( let timerEngine = TimerEngine(
@@ -32,30 +33,30 @@ final class TimerEngineTestabilityTests: XCTestCase {
enforceModeService: nil, enforceModeService: nil,
timeProvider: timeProvider timeProvider: timeProvider
) )
XCTAssertNotNil(timerEngine) XCTAssertNotNil(timerEngine)
XCTAssertEqual(timerEngine.timerStates.count, 0) XCTAssertEqual(timerEngine.timerStates.count, 0)
} }
func testTimerEngineUsesInjectedSettings() { func testTimerEngineUsesInjectedSettings() {
var settings = AppSettings.defaults var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = true settings.lookAwayTimer.enabled = true
settings.blinkTimer.enabled = false settings.blinkTimer.enabled = false
settings.postureTimer.enabled = false settings.postureTimer.enabled = false
testEnv.settingsManager.settings = settings testEnv.settingsManager.settings = settings
let timerEngine = testEnv.container.timerEngine let timerEngine = testEnv.container.timerEngine
timerEngine.start() timerEngine.start()
// Only lookAway should be active // Only lookAway should be active
let lookAwayTimer = timerEngine.timerStates.first { $0.key == .builtIn(.lookAway) } let lookAwayTimer = timerEngine.timerStates.first { $0.key == .builtIn(.lookAway) }
let blinkTimer = timerEngine.timerStates.first { $0.key == .builtIn(.blink) } let blinkTimer = timerEngine.timerStates.first { $0.key == .builtIn(.blink) }
XCTAssertNotNil(lookAwayTimer) XCTAssertNotNil(lookAwayTimer)
XCTAssertNil(blinkTimer) XCTAssertNil(blinkTimer)
} }
func testTimerEngineWithMockTimeProvider() { func testTimerEngineWithMockTimeProvider() {
let timeProvider = MockTimeProvider(startTime: Date()) let timeProvider = MockTimeProvider(startTime: Date())
let timerEngine = TimerEngine( let timerEngine = TimerEngine(
@@ -63,52 +64,51 @@ final class TimerEngineTestabilityTests: XCTestCase {
enforceModeService: nil, enforceModeService: nil,
timeProvider: timeProvider timeProvider: timeProvider
) )
// Start timers // Start timers
timerEngine.start() timerEngine.start()
// Advance time // Advance time
timeProvider.advance(by: 10) timeProvider.advance(by: 10)
// Timer engine should use the mocked time // Timer engine should use the mocked time
XCTAssertNotNil(timerEngine.timerStates) XCTAssertNotNil(timerEngine.timerStates)
} }
func testPauseAndResumeWithMocks() { func testPauseAndResumeWithMocks() {
let timerEngine = testEnv.container.timerEngine let timerEngine = testEnv.container.timerEngine
timerEngine.start() timerEngine.start()
timerEngine.pause() timerEngine.pause()
// Verify all timers are paused // Verify all timers are paused
for (_, state) in timerEngine.timerStates { for (_, state) in timerEngine.timerStates {
XCTAssertTrue(state.isPaused) XCTAssertTrue(state.isPaused)
} }
timerEngine.resume() timerEngine.resume()
// Verify all timers are resumed // Verify all timers are resumed
for (_, state) in timerEngine.timerStates { for (_, state) in timerEngine.timerStates {
XCTAssertFalse(state.isPaused) XCTAssertFalse(state.isPaused)
} }
} }
func testReminderEventPublishing() async throws { func testReminderEventPublishing() async throws {
let timerEngine = testEnv.container.timerEngine let timerEngine = testEnv.container.timerEngine
var receivedReminder: ReminderEvent? var receivedReminder: ReminderEvent?
timerEngine.$activeReminder timerEngine.$activeReminder
.sink { reminder in .sink { reminder in
receivedReminder = reminder receivedReminder = reminder
} }
.store(in: &cancellables) .store(in: &cancellables)
let timerId = TimerIdentifier.builtIn(.lookAway) let timerId = TimerIdentifier.builtIn(.lookAway)
timerEngine.triggerReminder(for: timerId) timerEngine.triggerReminder(for: timerId)
// Give time for publisher to fire try await Task.sleep(for: .milliseconds(10))
try await Task.sleep(nanoseconds: 10_000_000)
XCTAssertNotNil(receivedReminder) XCTAssertNotNil(receivedReminder)
} }
} }