From 5dc223ec9644021870dcee25bc34142f7a9df00d Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 15 Jan 2026 09:23:17 -0500 Subject: [PATCH] general: testability enhancements --- .gitignore | 1 + Gaze/AppDelegate.swift | 164 +++++------------- Gaze/Constants/AccessibilityIdentifiers.swift | 73 ++++++++ Gaze/Protocols/TimeProviding.swift | 44 +++++ Gaze/Protocols/TimerEngineProviding.swift | 86 +++++++++ Gaze/Protocols/WindowManaging.swift | 51 ++++++ Gaze/Services/ServiceContainer.swift | 62 ++++++- Gaze/Services/TimerEngine.swift | 16 +- Gaze/Services/WindowManager.swift | 108 ++++++++++++ Gaze/Views/Reminders/BlinkReminderView.swift | 1 + .../Reminders/LookAwayReminderView.swift | 3 + .../Views/Reminders/PostureReminderView.swift | 1 + Gaze/Views/Setup/EnforceModeSetupView.swift | 119 ++++++++----- Gaze/Views/WindowClasses.swift | 20 +++ GazeTests/Mocks/MockSettingsManager.swift | 86 ++++++++- GazeTests/Mocks/MockWindowManager.swift | 101 +++++++++++ GazeTests/TimerEngineTests.swift | 112 +++++++----- 17 files changed, 833 insertions(+), 215 deletions(-) create mode 100644 Gaze/Constants/AccessibilityIdentifiers.swift create mode 100644 Gaze/Protocols/TimeProviding.swift create mode 100644 Gaze/Protocols/TimerEngineProviding.swift create mode 100644 Gaze/Protocols/WindowManaging.swift create mode 100644 Gaze/Services/WindowManager.swift create mode 100644 Gaze/Views/WindowClasses.swift create mode 100644 GazeTests/Mocks/MockWindowManager.swift diff --git a/.gitignore b/.gitignore index 8f5ff88..51a57e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ tasks +python_impl AGENTS.md *.log *.app diff --git a/Gaze/AppDelegate.swift b/Gaze/AppDelegate.swift index e153dd6..e9b9250 100644 --- a/Gaze/AppDelegate.swift +++ b/Gaze/AppDelegate.swift @@ -13,9 +13,8 @@ import SwiftUI class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { @Published var timerEngine: TimerEngine? private let settingsManager: SettingsManager = .shared + private let windowManager: WindowManaging private var updateManager: UpdateManager? - private var overlayReminderWindowController: NSWindowController? - private var subtleReminderWindowController: NSWindowController? private var cancellables = Set() private var hasStartedTimers = false @@ -24,6 +23,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { private var idleService: IdleMonitoringService? private var usageTrackingService: UsageTrackingService? + override init() { + self.windowManager = WindowManager.shared + super.init() + } + + /// Initializer for testing with injectable dependencies + init(windowManager: WindowManaging) { + self.windowManager = windowManager + super.init() + } + func applicationDidFinishLaunching(_ notification: Notification) { // Set activation policy to hide dock icon NSApplication.shared.setActivationPolicy(.accessory) @@ -146,7 +156,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { timerEngine?.$activeReminder .sink { [weak self] reminder in guard let reminder = reminder else { - self?.dismissOverlayReminder() + self?.windowManager.dismissOverlayReminder() return } self?.showReminder(reminder) @@ -155,116 +165,41 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } private func showReminder(_ event: ReminderEvent) { - let contentView: AnyView - let requiresFocus: Bool - switch event { case .lookAwayTriggered(let countdownSeconds): - contentView = AnyView( - LookAwayReminderView(countdownSeconds: countdownSeconds) { [weak self] in - self?.timerEngine?.dismissReminder() - } - ) - requiresFocus = true + let view = LookAwayReminderView(countdownSeconds: countdownSeconds) { [weak self] in + self?.timerEngine?.dismissReminder() + } + windowManager.showReminderWindow(view, windowType: .overlay) + case .blinkTriggered: let sizePercentage = settingsManager.settings.subtleReminderSize.percentage - contentView = AnyView( - BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in - self?.timerEngine?.dismissReminder() - } - ) - requiresFocus = false + let view = BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in + self?.timerEngine?.dismissReminder() + } + windowManager.showReminderWindow(view, windowType: .subtle) + case .postureTriggered: let sizePercentage = settingsManager.settings.subtleReminderSize.percentage - contentView = AnyView( - PostureReminderView(sizePercentage: sizePercentage) { [weak self] in - self?.timerEngine?.dismissReminder() - } - ) - requiresFocus = false + let view = PostureReminderView(sizePercentage: sizePercentage) { [weak self] in + self?.timerEngine?.dismissReminder() + } + windowManager.showReminderWindow(view, windowType: .subtle) + case .userTimerTriggered(let timer): if timer.type == .overlay { - contentView = AnyView( - UserTimerOverlayReminderView(timer: timer) { [weak self] in - self?.timerEngine?.dismissReminder() - } - ) - requiresFocus = true + let view = UserTimerOverlayReminderView(timer: timer) { [weak self] in + self?.timerEngine?.dismissReminder() + } + windowManager.showReminderWindow(view, windowType: .overlay) } else { let sizePercentage = settingsManager.settings.subtleReminderSize.percentage - contentView = AnyView( - UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { - [weak self] in - self?.timerEngine?.dismissReminder() - } - ) - requiresFocus = false + let view = UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { [weak self] in + self?.timerEngine?.dismissReminder() + } + windowManager.showReminderWindow(view, windowType: .subtle) } } - - showReminderWindow(contentView, requiresFocus: requiresFocus, isOverlay: requiresFocus) - } - - private func showReminderWindow(_ content: AnyView, requiresFocus: Bool, isOverlay: Bool) { - guard let screen = NSScreen.main else { return } - - let window: NSWindow - if requiresFocus { - window = KeyableWindow( - contentRect: screen.frame, - styleMask: [.borderless, .fullSizeContentView], - backing: .buffered, - defer: false - ) - } else { - window = NonKeyWindow( - contentRect: screen.frame, - styleMask: [.borderless, .fullSizeContentView], - backing: .buffered, - defer: false - ) - } - - window.identifier = WindowIdentifiers.reminder - window.level = .floating - window.isOpaque = false - window.backgroundColor = .clear - window.contentView = NSHostingView(rootView: content) - window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - - // Allow mouse events only for overlay reminders (they need dismiss button) - // Subtle reminders should be completely transparent to mouse input - window.acceptsMouseMovedEvents = requiresFocus - window.ignoresMouseEvents = !requiresFocus - - let windowController = NSWindowController(window: window) - windowController.showWindow(nil) - - if requiresFocus { - window.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - } else { - window.orderFront(nil) - } - - // Track overlay and subtle reminders separately - if isOverlay { - overlayReminderWindowController?.close() - overlayReminderWindowController = windowController - } else { - subtleReminderWindowController?.close() - subtleReminderWindowController = windowController - } - } - - private func dismissOverlayReminder() { - overlayReminderWindowController?.close() - overlayReminderWindowController = nil - } - - private func dismissSubtleReminder() { - subtleReminderWindowController?.close() - subtleReminderWindowController = nil } func openSettings(tab: Int = 0) { @@ -272,8 +207,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self else { return } - SettingsWindowPresenter.shared.show( - settingsManager: self.settingsManager, initialTab: tab) + windowManager.showSettings(settingsManager: self.settingsManager, initialTab: tab) } } @@ -282,33 +216,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self else { return } - OnboardingWindowPresenter.shared.show(settingsManager: self.settingsManager) + windowManager.showOnboarding(settingsManager: self.settingsManager) } } private func handleMenuDismissal() { NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil) - dismissOverlayReminder() + windowManager.dismissOverlayReminder() } } - -class KeyableWindow: NSWindow { - override var canBecomeKey: Bool { - return true - } - - override var canBecomeMain: Bool { - return true - } -} - -class NonKeyWindow: NSWindow { - override var canBecomeKey: Bool { - return false - } - - override var canBecomeMain: Bool { - return false - } -} diff --git a/Gaze/Constants/AccessibilityIdentifiers.swift b/Gaze/Constants/AccessibilityIdentifiers.swift new file mode 100644 index 0000000..c495f59 --- /dev/null +++ b/Gaze/Constants/AccessibilityIdentifiers.swift @@ -0,0 +1,73 @@ +// +// AccessibilityIdentifiers.swift +// Gaze +// +// Centralized accessibility identifiers for UI testing. +// + +import Foundation + +/// Centralized accessibility identifiers for UI elements. +/// Use these in SwiftUI views with `.accessibilityIdentifier()` modifier. +enum AccessibilityIdentifiers { + + // MARK: - Reminders + + enum Reminders { + static let lookAwayView = "reminder.lookAway" + static let blinkView = "reminder.blink" + static let postureView = "reminder.posture" + static let userTimerView = "reminder.userTimer" + static let userTimerOverlayView = "reminder.userTimerOverlay" + static let dismissButton = "reminder.dismissButton" + static let countdownLabel = "reminder.countdown" + } + + // MARK: - Menu Bar + + enum MenuBar { + static let contentView = "menuBar.content" + static let timerRow = "menuBar.timerRow" + static let pauseButton = "menuBar.pauseButton" + static let resumeButton = "menuBar.resumeButton" + static let skipButton = "menuBar.skipButton" + static let settingsButton = "menuBar.settingsButton" + static let quitButton = "menuBar.quitButton" + } + + // MARK: - Settings + + enum Settings { + static let window = "settings.window" + static let generalTab = "settings.tab.general" + static let timersTab = "settings.tab.timers" + static let smartModeTab = "settings.tab.smartMode" + static let aboutTab = "settings.tab.about" + + // Timer settings + static let lookAwayToggle = "settings.lookAway.toggle" + static let lookAwayInterval = "settings.lookAway.interval" + static let blinkToggle = "settings.blink.toggle" + static let blinkInterval = "settings.blink.interval" + static let postureToggle = "settings.posture.toggle" + static let postureInterval = "settings.posture.interval" + + // General settings + static let launchAtLoginToggle = "settings.launchAtLogin.toggle" + static let playSoundsToggle = "settings.playSounds.toggle" + } + + // MARK: - Onboarding + + enum Onboarding { + static let window = "onboarding.window" + static let welcomePage = "onboarding.page.welcome" + static let lookAwayPage = "onboarding.page.lookAway" + static let blinkPage = "onboarding.page.blink" + static let posturePage = "onboarding.page.posture" + static let generalPage = "onboarding.page.general" + static let completionPage = "onboarding.page.completion" + static let continueButton = "onboarding.button.continue" + static let backButton = "onboarding.button.back" + } +} diff --git a/Gaze/Protocols/TimeProviding.swift b/Gaze/Protocols/TimeProviding.swift new file mode 100644 index 0000000..1135700 --- /dev/null +++ b/Gaze/Protocols/TimeProviding.swift @@ -0,0 +1,44 @@ +// +// TimeProviding.swift +// Gaze +// +// Protocol for abstracting time sources to enable deterministic testing. +// + +import Foundation + +/// Protocol for providing current time, enabling deterministic tests. +protocol TimeProviding { + /// Returns the current date/time + func now() -> Date +} + +/// Default implementation that uses the system clock +struct SystemTimeProvider: TimeProviding { + func now() -> Date { + Date() + } +} + +/// Test implementation that allows manual time control +final class MockTimeProvider: TimeProviding { + private var currentTime: Date + + init(startTime: Date = Date()) { + self.currentTime = startTime + } + + func now() -> Date { + currentTime + } + + /// Advances time by the specified interval + func advance(by interval: TimeInterval) { + currentTime = currentTime.addingTimeInterval(interval) + } + + /// Sets the current time to a specific date + func setTime(_ date: Date) { + currentTime = date + } +} diff --git a/Gaze/Protocols/TimerEngineProviding.swift b/Gaze/Protocols/TimerEngineProviding.swift new file mode 100644 index 0000000..c6a246c --- /dev/null +++ b/Gaze/Protocols/TimerEngineProviding.swift @@ -0,0 +1,86 @@ +// +// TimerEngineProviding.swift +// Gaze +// +// Protocol abstraction for TimerEngine to enable dependency injection and testing. +// + +import Combine +import Foundation + +/// Protocol that defines the interface for timer engine functionality. +/// This abstraction allows for dependency injection and easy mocking in tests. +@MainActor +protocol TimerEngineProviding: AnyObject, ObservableObject { + /// Current timer states for all active timers + var timerStates: [TimerIdentifier: TimerState] { get } + + /// Publisher for timer states changes + var timerStatesPublisher: Published<[TimerIdentifier: TimerState]>.Publisher { get } + + /// Currently active reminder, if any + var activeReminder: ReminderEvent? { get set } + + /// Publisher for active reminder changes + var activeReminderPublisher: Published.Publisher { get } + + /// Starts all enabled timers + func start() + + /// Stops all timers + func stop() + + /// Pauses all timers + func pause() + + /// Resumes all timers + func resume() + + /// Pauses a specific timer + func pauseTimer(identifier: TimerIdentifier) + + /// Resumes a specific timer + func resumeTimer(identifier: TimerIdentifier) + + /// Skips the next reminder for a specific timer and resets it + func skipNext(identifier: TimerIdentifier) + + /// Dismisses the current active reminder + func dismissReminder() + + /// Triggers a reminder for a specific timer + func triggerReminder(for identifier: TimerIdentifier) + + /// Gets the time remaining for a specific timer + func getTimeRemaining(for identifier: TimerIdentifier) -> TimeInterval + + /// Gets a formatted string of time remaining for a specific timer + func getFormattedTimeRemaining(for identifier: TimerIdentifier) -> String + + /// Checks if a timer is currently paused + func isTimerPaused(_ identifier: TimerIdentifier) -> Bool + + /// Handles system sleep event + func handleSystemSleep() + + /// Handles system wake event + func handleSystemWake() + + /// Sets up smart mode with fullscreen and idle detection services + func setupSmartMode( + fullscreenService: FullscreenDetectionService?, + idleService: IdleMonitoringService? + ) +} + +// MARK: - TimerEngine conformance + +extension TimerEngine: TimerEngineProviding { + var timerStatesPublisher: Published<[TimerIdentifier: TimerState]>.Publisher { + $timerStates + } + + var activeReminderPublisher: Published.Publisher { + $activeReminder + } +} diff --git a/Gaze/Protocols/WindowManaging.swift b/Gaze/Protocols/WindowManaging.swift new file mode 100644 index 0000000..9e7b0a1 --- /dev/null +++ b/Gaze/Protocols/WindowManaging.swift @@ -0,0 +1,51 @@ +// +// WindowManaging.swift +// Gaze +// +// Protocol abstraction for window management to enable dependency injection and testing. +// + +import AppKit +import SwiftUI + +/// Represents the type of reminder window to display +enum ReminderWindowType { + case overlay // Full-screen, focus-stealing windows (lookAway, user timer overlays) + case subtle // Non-intrusive windows (blink, posture, user timer subtle) +} + +/// Protocol that defines the interface for window management. +/// This abstraction allows for dependency injection and easy mocking in tests. +@MainActor +protocol WindowManaging: AnyObject { + /// Shows a reminder window with the given content + /// - Parameters: + /// - content: The SwiftUI view to display + /// - windowType: The type of reminder window + func showReminderWindow(_ content: Content, windowType: ReminderWindowType) + + /// Dismisses the overlay reminder window + func dismissOverlayReminder() + + /// Dismisses the subtle reminder window + func dismissSubtleReminder() + + /// Dismisses all reminder windows + func dismissAllReminders() + + /// Shows the settings window + /// - Parameters: + /// - settingsManager: The settings manager to use + /// - initialTab: The initial tab to display + func showSettings(settingsManager: any SettingsProviding, initialTab: Int) + + /// Shows the onboarding window + /// - Parameter settingsManager: The settings manager to use + func showOnboarding(settingsManager: any SettingsProviding) + + /// Whether an overlay reminder is currently visible + var isOverlayReminderVisible: Bool { get } + + /// Whether a subtle reminder is currently visible + var isSubtleReminderVisible: Bool { get } +} diff --git a/Gaze/Services/ServiceContainer.swift b/Gaze/Services/ServiceContainer.swift index 9bb94d6..0f283a8 100644 --- a/Gaze/Services/ServiceContainer.swift +++ b/Gaze/Services/ServiceContainer.swift @@ -5,6 +5,7 @@ // Dependency injection container for managing service instances. // +import Combine import Foundation /// A simple dependency injection container for managing service instances. @@ -45,7 +46,7 @@ final class ServiceContainer { /// Creates a test container with injectable dependencies /// - Parameters: - /// - settingsManager: The settings manager to use (defaults to MockSettingsManager in tests) + /// - settingsManager: The settings manager to use /// - enforceModeService: The enforce mode service to use init( settingsManager: any SettingsProviding, @@ -69,6 +70,11 @@ final class ServiceContainer { return engine } + /// Sets a custom timer engine (useful for testing) + func setTimerEngine(_ engine: TimerEngine) { + _timerEngine = engine + } + /// Sets up smart mode services func setupSmartModeServices() { let settings = settingsManager.settings @@ -104,9 +110,57 @@ final class ServiceContainer { usageTrackingService = nil } - /// Creates a new container configured for testing + /// Creates a new container configured for testing with default mock settings static func forTesting(settings: AppSettings = .defaults) -> ServiceContainer { - // We need to create this at runtime in tests using MockSettingsManager - fatalError("Use init(settingsManager:) directly in tests") + let mockSettings = MockSettingsManager(settings: settings) + return ServiceContainer(settingsManager: mockSettings) } } + +/// A mock settings manager for use in ServiceContainer.forTesting() +/// This is a minimal implementation - use the full MockSettingsManager from tests for more features +@MainActor +final class MockSettingsManager: ObservableObject, SettingsProviding { + @Published var settings: AppSettings + + var settingsPublisher: Published.Publisher { + $settings + } + + private let timerConfigKeyPaths: [TimerType: WritableKeyPath] = [ + .lookAway: \.lookAwayTimer, + .blink: \.blinkTimer, + .posture: \.postureTimer, + ] + + init(settings: AppSettings = .defaults) { + self.settings = settings + } + + func timerConfiguration(for type: TimerType) -> TimerConfiguration { + guard let keyPath = timerConfigKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + return settings[keyPath: keyPath] + } + + func updateTimerConfiguration(for type: TimerType, configuration: TimerConfiguration) { + guard let keyPath = timerConfigKeyPaths[type] else { + preconditionFailure("Unknown timer type: \(type)") + } + settings[keyPath: keyPath] = configuration + } + + func allTimerConfigurations() -> [TimerType: TimerConfiguration] { + var configs: [TimerType: TimerConfiguration] = [:] + for (type, keyPath) in timerConfigKeyPaths { + configs[type] = settings[keyPath: keyPath] + } + return configs + } + + func save() {} + func saveImmediately() {} + func load() {} + func resetToDefaults() { settings = .defaults } +} diff --git a/Gaze/Services/TimerEngine.swift b/Gaze/Services/TimerEngine.swift index f38c275..41f5696 100644 --- a/Gaze/Services/TimerEngine.swift +++ b/Gaze/Services/TimerEngine.swift @@ -17,6 +17,9 @@ class TimerEngine: ObservableObject { private let settingsProvider: any SettingsProviding private var sleepStartTime: Date? + /// Time provider for deterministic testing (defaults to system time) + private let timeProvider: TimeProviding + // For enforce mode integration private var enforceModeService: EnforceModeService? @@ -25,9 +28,14 @@ class TimerEngine: ObservableObject { private var idleService: IdleMonitoringService? private var cancellables = Set() - init(settingsManager: any SettingsProviding, enforceModeService: EnforceModeService? = nil) { + init( + settingsManager: any SettingsProviding, + enforceModeService: EnforceModeService? = nil, + timeProvider: TimeProviding = SystemTimeProvider() + ) { self.settingsProvider = settingsManager self.enforceModeService = enforceModeService ?? EnforceModeService.shared + self.timeProvider = timeProvider Task { @MainActor in self.enforceModeService?.setTimerEngine(self) @@ -300,7 +308,7 @@ class TimerEngine: ObservableObject { guard !state.isPaused else { continue } guard state.isActive else { continue } - if state.targetDate < Date() - 3.0 { + if state.targetDate < timeProvider.now() - 3.0 { skipNext(identifier: identifier) continue } @@ -365,7 +373,7 @@ class TimerEngine: ObservableObject { /// - Saves current time for elapsed calculation /// - Pauses all active timers func handleSystemSleep() { - sleepStartTime = Date() + sleepStartTime = timeProvider.now() for (id, var state) in timerStates { state.pauseReasons.insert(.system) state.isPaused = true @@ -387,7 +395,7 @@ class TimerEngine: ObservableObject { sleepStartTime = nil } - let elapsedSeconds = Int(Date().timeIntervalSince(sleepStart)) + let elapsedSeconds = Int(timeProvider.now().timeIntervalSince(sleepStart)) guard elapsedSeconds >= 1 else { for (id, var state) in timerStates { diff --git a/Gaze/Services/WindowManager.swift b/Gaze/Services/WindowManager.swift new file mode 100644 index 0000000..faab9d1 --- /dev/null +++ b/Gaze/Services/WindowManager.swift @@ -0,0 +1,108 @@ +// +// WindowManager.swift +// Gaze +// +// Concrete implementation of WindowManaging for production use. +// + +import AppKit +import SwiftUI + +/// Production implementation of WindowManaging that creates real AppKit windows. +@MainActor +final class WindowManager: WindowManaging { + static let shared = WindowManager() + + private var overlayReminderWindowController: NSWindowController? + private var subtleReminderWindowController: NSWindowController? + + var isOverlayReminderVisible: Bool { + overlayReminderWindowController?.window?.isVisible ?? false + } + + var isSubtleReminderVisible: Bool { + subtleReminderWindowController?.window?.isVisible ?? false + } + + private init() {} + + func showReminderWindow(_ content: Content, windowType: ReminderWindowType) { + guard let screen = NSScreen.main else { return } + + let requiresFocus = windowType == .overlay + let window: NSWindow + + if requiresFocus { + window = KeyableWindow( + contentRect: screen.frame, + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + } else { + window = NonKeyWindow( + contentRect: screen.frame, + styleMask: [.borderless, .fullSizeContentView], + backing: .buffered, + defer: false + ) + } + + window.identifier = WindowIdentifiers.reminder + window.level = .floating + window.isOpaque = false + window.backgroundColor = .clear + window.contentView = NSHostingView(rootView: content) + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + window.acceptsMouseMovedEvents = requiresFocus + window.ignoresMouseEvents = !requiresFocus + + let windowController = NSWindowController(window: window) + windowController.showWindow(nil) + + if requiresFocus { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } else { + window.orderFront(nil) + } + + switch windowType { + case .overlay: + overlayReminderWindowController?.close() + overlayReminderWindowController = windowController + case .subtle: + subtleReminderWindowController?.close() + subtleReminderWindowController = windowController + } + } + + func dismissOverlayReminder() { + overlayReminderWindowController?.close() + overlayReminderWindowController = nil + } + + func dismissSubtleReminder() { + subtleReminderWindowController?.close() + subtleReminderWindowController = nil + } + + func dismissAllReminders() { + dismissOverlayReminder() + dismissSubtleReminder() + } + + func showSettings(settingsManager: any SettingsProviding, initialTab: Int) { + // Use the existing presenter for now + if let realSettings = settingsManager as? SettingsManager { + SettingsWindowPresenter.shared.show(settingsManager: realSettings, initialTab: initialTab) + } + } + + func showOnboarding(settingsManager: any SettingsProviding) { + // Use the existing presenter for now + if let realSettings = settingsManager as? SettingsManager { + OnboardingWindowPresenter.shared.show(settingsManager: realSettings) + } + } +} diff --git a/Gaze/Views/Reminders/BlinkReminderView.swift b/Gaze/Views/Reminders/BlinkReminderView.swift index 6eef4a1..f2dfe8e 100644 --- a/Gaze/Views/Reminders/BlinkReminderView.swift +++ b/Gaze/Views/Reminders/BlinkReminderView.swift @@ -46,6 +46,7 @@ struct BlinkReminderView: View { .opacity(opacity) .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.top, screenHeight * 0.05) + .accessibilityIdentifier(AccessibilityIdentifiers.Reminders.blinkView) .onAppear { startAnimation() } diff --git a/Gaze/Views/Reminders/LookAwayReminderView.swift b/Gaze/Views/Reminders/LookAwayReminderView.swift index b599e3b..2f3ec44 100644 --- a/Gaze/Views/Reminders/LookAwayReminderView.swift +++ b/Gaze/Views/Reminders/LookAwayReminderView.swift @@ -64,6 +64,7 @@ struct LookAwayReminderView: View { .font(.system(size: 48, weight: .bold)) .foregroundColor(.white) .monospacedDigit() + .accessibilityIdentifier(AccessibilityIdentifiers.Reminders.countdownLabel) } Text("Press ESC or Space to skip") @@ -81,11 +82,13 @@ struct LookAwayReminderView: View { .foregroundColor(.white.opacity(0.7)) } .buttonStyle(.plain) + .accessibilityIdentifier(AccessibilityIdentifiers.Reminders.dismissButton) .padding(30) } Spacer() } } + .accessibilityIdentifier(AccessibilityIdentifiers.Reminders.lookAwayView) .onAppear { startCountdown() setupKeyMonitor() diff --git a/Gaze/Views/Reminders/PostureReminderView.swift b/Gaze/Views/Reminders/PostureReminderView.swift index cddda44..3b7938f 100644 --- a/Gaze/Views/Reminders/PostureReminderView.swift +++ b/Gaze/Views/Reminders/PostureReminderView.swift @@ -28,6 +28,7 @@ struct PostureReminderView: View { .offset(y: yOffset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .padding(.top, screenHeight * 0.075) + .accessibilityIdentifier(AccessibilityIdentifiers.Reminders.postureView) .onAppear { startAnimation() } diff --git a/Gaze/Views/Setup/EnforceModeSetupView.swift b/Gaze/Views/Setup/EnforceModeSetupView.swift index cadeb95..84a8a4d 100644 --- a/Gaze/Views/Setup/EnforceModeSetupView.swift +++ b/Gaze/Views/Setup/EnforceModeSetupView.swift @@ -25,8 +25,8 @@ struct EnforceModeSetupView: View { @ObservedObject var calibrationManager = CalibrationManager.shared var body: some View { - VStack(spacing: 0) { - ScrollView { + ScrollView { + VStack(spacing: 0) { VStack(spacing: 16) { Image(systemName: "video.fill") .font(.system(size: 60)) @@ -153,7 +153,7 @@ struct EnforceModeSetupView: View { .buttonStyle(.borderedProminent) .controlSize(.large) } - + private var calibrationSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { @@ -163,17 +163,20 @@ struct EnforceModeSetupView: View { Text("Eye Tracking Calibration") .font(.headline) } - + if calibrationManager.calibrationData.isComplete { VStack(alignment: .leading, spacing: 8) { Text(calibrationManager.getCalibrationSummary()) .font(.caption) .foregroundColor(.secondary) - + if calibrationManager.needsRecalibration() { - Label("Calibration expired - recalibration recommended", systemImage: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundColor(.orange) + Label( + "Calibration expired - recalibration recommended", + systemImage: "exclamationmark.triangle.fill" + ) + .font(.caption) + .foregroundColor(.orange) } else { Label("Calibration active and valid", systemImage: "checkmark.circle.fill") .font(.caption) @@ -185,13 +188,15 @@ struct EnforceModeSetupView: View { .font(.caption) .foregroundColor(.secondary) } - + Button(action: { showCalibrationWindow = true }) { HStack { Image(systemName: "target") - Text(calibrationManager.calibrationData.isComplete ? "Recalibrate" : "Run Calibration") + Text( + calibrationManager.calibrationData.isComplete + ? "Recalibrate" : "Run Calibration") } .frame(maxWidth: .infinity) .padding(.vertical, 8) @@ -200,7 +205,9 @@ struct EnforceModeSetupView: View { .controlSize(.regular) } .padding() - .glassEffectIfAvailable(GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12)) + .glassEffectIfAvailable( + GlassStyle.regular.tint(.blue.opacity(0.1)), in: .rect(cornerRadius: 12) + ) .sheet(isPresented: $showCalibrationWindow) { EyeTrackingCalibrationView() } @@ -427,12 +434,15 @@ struct EnforceModeSetupView: View { Button(action: { eyeTrackingService.enableDebugLogging.toggle() }) { - Image(systemName: eyeTrackingService.enableDebugLogging ? "ant.circle.fill" : "ant.circle") - .foregroundColor(eyeTrackingService.enableDebugLogging ? .orange : .secondary) + Image( + systemName: eyeTrackingService.enableDebugLogging + ? "ant.circle.fill" : "ant.circle" + ) + .foregroundColor(eyeTrackingService.enableDebugLogging ? .orange : .secondary) } .buttonStyle(.plain) .help("Toggle console debug logging") - + Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") { withAnimation { showAdvancedSettings.toggle() @@ -441,40 +451,54 @@ struct EnforceModeSetupView: View { .buttonStyle(.bordered) .controlSize(.small) } - + // Debug info always visible when tracking VStack(alignment: .leading, spacing: 8) { Text("Live Values:") .font(.caption) .fontWeight(.semibold) .foregroundColor(.secondary) - + if let leftRatio = eyeTrackingService.debugLeftPupilRatio, - let rightRatio = eyeTrackingService.debugRightPupilRatio { + let rightRatio = eyeTrackingService.debugRightPupilRatio + { HStack(spacing: 16) { VStack(alignment: .leading, spacing: 2) { Text("Left Pupil: \(String(format: "%.3f", leftRatio))") .font(.caption2) .foregroundColor( - !trackingConstants.minPupilEnabled && !trackingConstants.maxPupilEnabled ? .secondary : - (leftRatio < trackingConstants.minPupilRatio || leftRatio > trackingConstants.maxPupilRatio) ? .orange : .green + !trackingConstants.minPupilEnabled + && !trackingConstants.maxPupilEnabled + ? .secondary + : (leftRatio < trackingConstants.minPupilRatio + || leftRatio > trackingConstants.maxPupilRatio) + ? .orange : .green ) Text("Right Pupil: \(String(format: "%.3f", rightRatio))") .font(.caption2) .foregroundColor( - !trackingConstants.minPupilEnabled && !trackingConstants.maxPupilEnabled ? .secondary : - (rightRatio < trackingConstants.minPupilRatio || rightRatio > trackingConstants.maxPupilRatio) ? .orange : .green + !trackingConstants.minPupilEnabled + && !trackingConstants.maxPupilEnabled + ? .secondary + : (rightRatio < trackingConstants.minPupilRatio + || rightRatio > trackingConstants.maxPupilRatio) + ? .orange : .green ) } - + Spacer() - + VStack(alignment: .trailing, spacing: 2) { - Text("Range: \(String(format: "%.2f", trackingConstants.minPupilRatio)) - \(String(format: "%.2f", trackingConstants.maxPupilRatio))") - .font(.caption2) - .foregroundColor(.secondary) - let bothEyesOut = (leftRatio < trackingConstants.minPupilRatio || leftRatio > trackingConstants.maxPupilRatio) && - (rightRatio < trackingConstants.minPupilRatio || rightRatio > trackingConstants.maxPupilRatio) + Text( + "Range: \(String(format: "%.2f", trackingConstants.minPupilRatio)) - \(String(format: "%.2f", trackingConstants.maxPupilRatio))" + ) + .font(.caption2) + .foregroundColor(.secondary) + let bothEyesOut = + (leftRatio < trackingConstants.minPupilRatio + || leftRatio > trackingConstants.maxPupilRatio) + && (rightRatio < trackingConstants.minPupilRatio + || rightRatio > trackingConstants.maxPupilRatio) Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓") .font(.caption2) .foregroundColor(bothEyesOut ? .orange : .green) @@ -485,34 +509,45 @@ struct EnforceModeSetupView: View { .font(.caption2) .foregroundColor(.secondary) } - + if let yaw = eyeTrackingService.debugYaw, - let pitch = eyeTrackingService.debugPitch { + let pitch = eyeTrackingService.debugPitch + { HStack(spacing: 16) { VStack(alignment: .leading, spacing: 2) { Text("Yaw: \(String(format: "%.3f", yaw))") .font(.caption2) .foregroundColor( - !trackingConstants.yawEnabled ? .secondary : - abs(yaw) > trackingConstants.yawThreshold ? .orange : .green + !trackingConstants.yawEnabled + ? .secondary + : abs(yaw) > trackingConstants.yawThreshold + ? .orange : .green ) Text("Pitch: \(String(format: "%.3f", pitch))") .font(.caption2) .foregroundColor( - !trackingConstants.pitchUpEnabled && !trackingConstants.pitchDownEnabled ? .secondary : - (pitch > trackingConstants.pitchUpThreshold || pitch < trackingConstants.pitchDownThreshold) ? .orange : .green + !trackingConstants.pitchUpEnabled + && !trackingConstants.pitchDownEnabled + ? .secondary + : (pitch > trackingConstants.pitchUpThreshold + || pitch < trackingConstants.pitchDownThreshold) + ? .orange : .green ) } - + Spacer() - + VStack(alignment: .trailing, spacing: 2) { - Text("Yaw Max: \(String(format: "%.2f", trackingConstants.yawThreshold))") - .font(.caption2) - .foregroundColor(.secondary) - Text("Pitch: \(String(format: "%.2f", trackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", trackingConstants.pitchUpThreshold))") - .font(.caption2) - .foregroundColor(.secondary) + Text( + "Yaw Max: \(String(format: "%.2f", trackingConstants.yawThreshold))" + ) + .font(.caption2) + .foregroundColor(.secondary) + Text( + "Pitch: \(String(format: "%.2f", trackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", trackingConstants.pitchUpThreshold))" + ) + .font(.caption2) + .foregroundColor(.secondary) } } } diff --git a/Gaze/Views/WindowClasses.swift b/Gaze/Views/WindowClasses.swift new file mode 100644 index 0000000..f483b97 --- /dev/null +++ b/Gaze/Views/WindowClasses.swift @@ -0,0 +1,20 @@ +// +// WindowClasses.swift +// Gaze +// +// Custom NSWindow subclasses for different window behaviors. +// + +import AppKit + +/// Window that accepts keyboard and mouse focus (for overlay reminders) +class KeyableWindow: NSWindow { + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { true } +} + +/// Window that doesn't accept keyboard or mouse focus (for subtle reminders) +class NonKeyWindow: NSWindow { + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} diff --git a/GazeTests/Mocks/MockSettingsManager.swift b/GazeTests/Mocks/MockSettingsManager.swift index af42616..98e3ada 100644 --- a/GazeTests/Mocks/MockSettingsManager.swift +++ b/GazeTests/Mocks/MockSettingsManager.swift @@ -30,11 +30,17 @@ final class MockSettingsManager: ObservableObject, SettingsProviding { var saveCallCount = 0 var loadCallCount = 0 var resetToDefaultsCallCount = 0 + var saveImmediatelyCallCount = 0 + + /// Track timer configuration updates for verification + var timerConfigurationUpdates: [(TimerType, TimerConfiguration)] = [] init(settings: AppSettings = .defaults) { self.settings = settings } + // MARK: - SettingsProviding conformance + func timerConfiguration(for type: TimerType) -> TimerConfiguration { guard let keyPath = timerConfigKeyPaths[type] else { preconditionFailure("Unknown timer type: \(type)") @@ -47,6 +53,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding { preconditionFailure("Unknown timer type: \(type)") } settings[keyPath: keyPath] = configuration + timerConfigurationUpdates.append((type, configuration)) } func allTimerConfigurations() -> [TimerType: TimerConfiguration] { @@ -62,7 +69,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding { } func saveImmediately() { - saveCallCount += 1 + saveImmediatelyCallCount += 1 } func load() { @@ -73,4 +80,81 @@ final class MockSettingsManager: ObservableObject, SettingsProviding { resetToDefaultsCallCount += 1 settings = .defaults } + + // MARK: - Test helper methods + + /// Resets all call tracking counters + func resetCallTracking() { + saveCallCount = 0 + loadCallCount = 0 + resetToDefaultsCallCount = 0 + saveImmediatelyCallCount = 0 + timerConfigurationUpdates = [] + } + + /// Creates settings with all timers enabled + static func withAllTimersEnabled() -> MockSettingsManager { + var settings = AppSettings.defaults + settings.lookAwayTimer.enabled = true + settings.blinkTimer.enabled = true + settings.postureTimer.enabled = true + return MockSettingsManager(settings: settings) + } + + /// Creates settings with all timers disabled + static func withAllTimersDisabled() -> MockSettingsManager { + var settings = AppSettings.defaults + settings.lookAwayTimer.enabled = false + settings.blinkTimer.enabled = false + settings.postureTimer.enabled = false + return MockSettingsManager(settings: settings) + } + + /// Creates settings with onboarding completed + static func withOnboardingCompleted() -> MockSettingsManager { + var settings = AppSettings.defaults + settings.hasCompletedOnboarding = true + return MockSettingsManager(settings: settings) + } + + /// Creates settings with custom timer intervals (in seconds) + static func withTimerIntervals( + lookAway: Int = 20 * 60, + blink: Int = 7 * 60, + posture: Int = 30 * 60 + ) -> MockSettingsManager { + var settings = AppSettings.defaults + settings.lookAwayTimer.intervalSeconds = lookAway + settings.blinkTimer.intervalSeconds = blink + settings.postureTimer.intervalSeconds = posture + return MockSettingsManager(settings: settings) + } + + /// Enables a specific timer + func enableTimer(_ type: TimerType) { + guard let keyPath = timerConfigKeyPaths[type] else { return } + settings[keyPath: keyPath].enabled = true + } + + /// Disables a specific timer + func disableTimer(_ type: TimerType) { + guard let keyPath = timerConfigKeyPaths[type] else { return } + settings[keyPath: keyPath].enabled = false + } + + /// Sets a specific timer's interval + func setTimerInterval(_ type: TimerType, seconds: Int) { + guard let keyPath = timerConfigKeyPaths[type] else { return } + settings[keyPath: keyPath].intervalSeconds = seconds + } + + /// Adds a user timer + func addUserTimer(_ timer: UserTimer) { + settings.userTimers.append(timer) + } + + /// Removes all user timers + func clearUserTimers() { + settings.userTimers = [] + } } diff --git a/GazeTests/Mocks/MockWindowManager.swift b/GazeTests/Mocks/MockWindowManager.swift new file mode 100644 index 0000000..fb5087e --- /dev/null +++ b/GazeTests/Mocks/MockWindowManager.swift @@ -0,0 +1,101 @@ +// +// MockWindowManager.swift +// GazeTests +// +// A mock implementation of WindowManaging for isolated unit testing. +// + +import SwiftUI +@testable import Gaze + +/// A mock implementation of WindowManaging that doesn't create real windows. +/// This allows tests to run in complete isolation without affecting the UI. +@MainActor +final class MockWindowManager: WindowManaging { + + // MARK: - State tracking + + var isOverlayReminderVisible: Bool = false + var isSubtleReminderVisible: Bool = false + + // MARK: - Call tracking for verification + + var showReminderWindowCalls: [(windowType: ReminderWindowType, viewType: String)] = [] + var dismissOverlayReminderCallCount = 0 + var dismissSubtleReminderCallCount = 0 + var dismissAllRemindersCallCount = 0 + var showSettingsCalls: [Int] = [] + var showOnboardingCallCount = 0 + + /// The last window type shown + var lastShownWindowType: ReminderWindowType? + + // MARK: - WindowManaging conformance + + func showReminderWindow(_ content: Content, windowType: ReminderWindowType) { + let viewType = String(describing: type(of: content)) + showReminderWindowCalls.append((windowType: windowType, viewType: viewType)) + lastShownWindowType = windowType + + switch windowType { + case .overlay: + isOverlayReminderVisible = true + case .subtle: + isSubtleReminderVisible = true + } + } + + func dismissOverlayReminder() { + dismissOverlayReminderCallCount += 1 + isOverlayReminderVisible = false + } + + func dismissSubtleReminder() { + dismissSubtleReminderCallCount += 1 + isSubtleReminderVisible = false + } + + func dismissAllReminders() { + dismissAllRemindersCallCount += 1 + isOverlayReminderVisible = false + isSubtleReminderVisible = false + } + + func showSettings(settingsManager: any SettingsProviding, initialTab: Int) { + showSettingsCalls.append(initialTab) + } + + func showOnboarding(settingsManager: any SettingsProviding) { + showOnboardingCallCount += 1 + } + + // MARK: - Test helpers + + /// Resets all call tracking counters + func resetCallTracking() { + showReminderWindowCalls = [] + dismissOverlayReminderCallCount = 0 + dismissSubtleReminderCallCount = 0 + dismissAllRemindersCallCount = 0 + showSettingsCalls = [] + showOnboardingCallCount = 0 + lastShownWindowType = nil + isOverlayReminderVisible = false + isSubtleReminderVisible = false + } + + /// Returns the number of overlay windows shown + var overlayWindowsShownCount: Int { + showReminderWindowCalls.filter { $0.windowType == .overlay }.count + } + + /// Returns the number of subtle windows shown + var subtleWindowsShownCount: Int { + showReminderWindowCalls.filter { $0.windowType == .subtle }.count + } + + /// Checks if a specific view type was shown + func wasViewShown(containing typeName: String) -> Bool { + showReminderWindowCalls.contains { $0.viewType.contains(typeName) } + } +} diff --git a/GazeTests/TimerEngineTests.swift b/GazeTests/TimerEngineTests.swift index 663c1a9..f2dd510 100644 --- a/GazeTests/TimerEngineTests.swift +++ b/GazeTests/TimerEngineTests.swift @@ -12,25 +12,23 @@ import XCTest final class TimerEngineTests: XCTestCase { var timerEngine: TimerEngine! - var settingsManager: SettingsManager! + var mockSettings: MockSettingsManager! override func setUp() async throws { try await super.setUp() - settingsManager = SettingsManager.shared - UserDefaults.standard.removeObject(forKey: "gazeAppSettings") - settingsManager.load() - timerEngine = TimerEngine(settingsManager: settingsManager) + mockSettings = MockSettingsManager() + timerEngine = TimerEngine(settingsManager: mockSettings, enforceModeService: nil) } override func tearDown() async throws { timerEngine.stop() - UserDefaults.standard.removeObject(forKey: "gazeAppSettings") + mockSettings = nil try await super.tearDown() } func testTimerInitialization() { // Enable all timers for this test (blink is disabled by default) - settingsManager.settings.blinkTimer.enabled = true + mockSettings.enableTimer(.blink) timerEngine.start() XCTAssertEqual(timerEngine.timerStates.count, 3) @@ -60,7 +58,7 @@ final class TimerEngineTests: XCTestCase { } func testPauseAllTimers() { - settingsManager.settings.blinkTimer.enabled = true + mockSettings.enableTimer(.blink) timerEngine.start() timerEngine.pause() @@ -70,7 +68,7 @@ final class TimerEngineTests: XCTestCase { } func testResumeAllTimers() { - settingsManager.settings.blinkTimer.enabled = true + mockSettings.enableTimer(.blink) timerEngine.start() timerEngine.pause() timerEngine.resume() @@ -81,7 +79,7 @@ final class TimerEngineTests: XCTestCase { } func testSkipNext() { - settingsManager.settings.lookAwayTimer.intervalSeconds = 60 + mockSettings.setTimerInterval(.lookAway, seconds: 60) timerEngine.start() timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds = 10 @@ -123,8 +121,8 @@ final class TimerEngineTests: XCTestCase { } func testDismissReminderResetsTimer() { - settingsManager.settings.blinkTimer.enabled = true - settingsManager.settings.blinkTimer.intervalSeconds = 7 * 60 + mockSettings.enableTimer(.blink) + mockSettings.setTimerInterval(.blink, seconds: 7 * 60) timerEngine.start() timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds = 0 timerEngine.activeReminder = .blinkTriggered @@ -156,7 +154,7 @@ final class TimerEngineTests: XCTestCase { XCTAssertNotNil(timerEngine.activeReminder) if case .lookAwayTriggered(let countdown) = timerEngine.activeReminder { - XCTAssertEqual(countdown, settingsManager.settings.lookAwayCountdownSeconds) + XCTAssertEqual(countdown, mockSettings.settings.lookAwayCountdownSeconds) } else { XCTFail("Expected lookAwayTriggered reminder") } @@ -166,7 +164,7 @@ final class TimerEngineTests: XCTestCase { } func testTriggerReminderForBlink() { - settingsManager.settings.blinkTimer.enabled = true + mockSettings.enableTimer(.blink) timerEngine.start() timerEngine.triggerReminder(for: .builtIn(.blink)) @@ -260,7 +258,7 @@ final class TimerEngineTests: XCTestCase { } func testDismissBlinkReminderResumesTimer() { - settingsManager.settings.blinkTimer.enabled = true + mockSettings.enableTimer(.blink) timerEngine.start() timerEngine.triggerReminder(for: .builtIn(.blink)) @@ -281,9 +279,9 @@ final class TimerEngineTests: XCTestCase { } func testAllTimersStartWhenEnabled() { - settingsManager.settings.lookAwayTimer.enabled = true - settingsManager.settings.blinkTimer.enabled = true - settingsManager.settings.postureTimer.enabled = true + mockSettings.enableTimer(.lookAway) + mockSettings.enableTimer(.blink) + mockSettings.enableTimer(.posture) timerEngine.start() @@ -294,9 +292,9 @@ final class TimerEngineTests: XCTestCase { } func testAllTimersDisabled() { - settingsManager.settings.lookAwayTimer.enabled = false - settingsManager.settings.blinkTimer.enabled = false - settingsManager.settings.postureTimer.enabled = false + mockSettings.disableTimer(.lookAway) + mockSettings.disableTimer(.blink) + mockSettings.disableTimer(.posture) timerEngine.start() @@ -304,9 +302,9 @@ final class TimerEngineTests: XCTestCase { } func testPartialTimersEnabled() { - settingsManager.settings.lookAwayTimer.enabled = true - settingsManager.settings.blinkTimer.enabled = false - settingsManager.settings.postureTimer.enabled = true + mockSettings.enableTimer(.lookAway) + mockSettings.disableTimer(.blink) + mockSettings.enableTimer(.posture) timerEngine.start() @@ -325,7 +323,7 @@ final class TimerEngineTests: XCTestCase { intervalMinutes: 1, message: "Drink water" ) - settingsManager.settings.userTimers = [overlayTimer] + mockSettings.addUserTimer(overlayTimer) timerEngine.start() @@ -345,7 +343,6 @@ final class TimerEngineTests: XCTestCase { XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) // Now trigger a subtle reminder (blink) while overlay is still active - let previousActiveReminder = timerEngine.activeReminder timerEngine.triggerReminder(for: .builtIn(.blink)) // The activeReminder should be replaced with the blink reminder @@ -360,16 +357,9 @@ final class TimerEngineTests: XCTestCase { // Both timers should be paused (the one that triggered their reminder) XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) XCTAssertTrue(timerEngine.isTimerPaused(.builtIn(.blink))) - - // The key insight: Even though TimerEngine only tracks one activeReminder, - // AppDelegate now tracks overlay and subtle windows separately, so both - // reminders can be displayed simultaneously without interference } func testOverlayReminderDoesNotBlockSubtleReminders() { - // This test verifies the fix for the bug where a subtle reminder - // would cause an overlay reminder to get stuck - // Setup overlay user timer let overlayTimer = UserTimer( title: "Stand Up", @@ -377,9 +367,9 @@ final class TimerEngineTests: XCTestCase { timeOnScreenSeconds: 10, intervalMinutes: 1 ) - settingsManager.settings.userTimers = [overlayTimer] - settingsManager.settings.blinkTimer.enabled = true - settingsManager.settings.blinkTimer.intervalSeconds = 60 + mockSettings.addUserTimer(overlayTimer) + mockSettings.enableTimer(.blink) + mockSettings.setTimerInterval(.blink, seconds: 60) timerEngine.start() @@ -412,9 +402,53 @@ final class TimerEngineTests: XCTestCase { XCTAssertFalse(timerEngine.isTimerPaused(.builtIn(.blink))) XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 60) - // The overlay timer should still be paused (user needs to dismiss it manually) - // Note: In the actual app, AppDelegate tracks this window separately and it - // remains visible even after the subtle reminder dismisses + // The overlay timer should still be paused XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) } + + // MARK: - Tests using injectable time provider + + func testTimerEngineWithMockTimeProvider() { + let mockTime = MockTimeProvider(startTime: Date()) + let engine = TimerEngine( + settingsManager: mockSettings, + enforceModeService: nil, + timeProvider: mockTime + ) + + engine.start() + XCTAssertNotNil(engine.timerStates[.builtIn(.lookAway)]) + + engine.stop() + } + + func testSystemSleepWakeWithMockTime() { + let startDate = Date() + let mockTime = MockTimeProvider(startTime: startDate) + let engine = TimerEngine( + settingsManager: mockSettings, + enforceModeService: nil, + timeProvider: mockTime + ) + + engine.start() + let initialRemaining = engine.timerStates[.builtIn(.lookAway)]?.remainingSeconds ?? 0 + + // Simulate sleep + engine.handleSystemSleep() + XCTAssertTrue(engine.isTimerPaused(.builtIn(.lookAway))) + + // Advance mock time by 5 minutes + mockTime.advance(by: 300) + + // Simulate wake + engine.handleSystemWake() + + // Timer should resume and have adjusted remaining time + XCTAssertFalse(engine.isTimerPaused(.builtIn(.lookAway))) + let newRemaining = engine.timerStates[.builtIn(.lookAway)]?.remainingSeconds ?? 0 + XCTAssertEqual(newRemaining, initialRemaining - 300) + + engine.stop() + } }