general: testability enhancements

This commit is contained in:
Michael Freno
2026-01-15 09:23:17 -05:00
parent 429d4ff32e
commit 5dc223ec96
17 changed files with 833 additions and 215 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
tasks tasks
python_impl
AGENTS.md AGENTS.md
*.log *.log
*.app *.app

View File

@@ -13,9 +13,8 @@ import SwiftUI
class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
@Published var timerEngine: TimerEngine? @Published var timerEngine: TimerEngine?
private let settingsManager: SettingsManager = .shared private let settingsManager: SettingsManager = .shared
private let windowManager: WindowManaging
private var updateManager: UpdateManager? private var updateManager: UpdateManager?
private var overlayReminderWindowController: NSWindowController?
private var subtleReminderWindowController: NSWindowController?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var hasStartedTimers = false private var hasStartedTimers = false
@@ -24,6 +23,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
private var idleService: IdleMonitoringService? private var idleService: IdleMonitoringService?
private var usageTrackingService: UsageTrackingService? 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) { func applicationDidFinishLaunching(_ notification: Notification) {
// Set activation policy to hide dock icon // Set activation policy to hide dock icon
NSApplication.shared.setActivationPolicy(.accessory) NSApplication.shared.setActivationPolicy(.accessory)
@@ -146,7 +156,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
timerEngine?.$activeReminder timerEngine?.$activeReminder
.sink { [weak self] reminder in .sink { [weak self] reminder in
guard let reminder = reminder else { guard let reminder = reminder else {
self?.dismissOverlayReminder() self?.windowManager.dismissOverlayReminder()
return return
} }
self?.showReminder(reminder) self?.showReminder(reminder)
@@ -155,116 +165,41 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
} }
private func showReminder(_ event: ReminderEvent) { private func showReminder(_ event: ReminderEvent) {
let contentView: AnyView
let requiresFocus: Bool
switch event { switch event {
case .lookAwayTriggered(let countdownSeconds): case .lookAwayTriggered(let countdownSeconds):
contentView = AnyView( let view = LookAwayReminderView(countdownSeconds: countdownSeconds) { [weak self] in
LookAwayReminderView(countdownSeconds: countdownSeconds) { [weak self] in self?.timerEngine?.dismissReminder()
self?.timerEngine?.dismissReminder() }
} windowManager.showReminderWindow(view, windowType: .overlay)
)
requiresFocus = true
case .blinkTriggered: case .blinkTriggered:
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
contentView = AnyView( let view = BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in
BlinkReminderView(sizePercentage: sizePercentage) { [weak self] in self?.timerEngine?.dismissReminder()
self?.timerEngine?.dismissReminder() }
} windowManager.showReminderWindow(view, windowType: .subtle)
)
requiresFocus = false
case .postureTriggered: case .postureTriggered:
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
contentView = AnyView( let view = PostureReminderView(sizePercentage: sizePercentage) { [weak self] in
PostureReminderView(sizePercentage: sizePercentage) { [weak self] in self?.timerEngine?.dismissReminder()
self?.timerEngine?.dismissReminder() }
} windowManager.showReminderWindow(view, windowType: .subtle)
)
requiresFocus = false
case .userTimerTriggered(let timer): case .userTimerTriggered(let timer):
if timer.type == .overlay { if timer.type == .overlay {
contentView = AnyView( let view = UserTimerOverlayReminderView(timer: timer) { [weak self] in
UserTimerOverlayReminderView(timer: timer) { [weak self] in self?.timerEngine?.dismissReminder()
self?.timerEngine?.dismissReminder() }
} windowManager.showReminderWindow(view, windowType: .overlay)
)
requiresFocus = true
} else { } else {
let sizePercentage = settingsManager.settings.subtleReminderSize.percentage let sizePercentage = settingsManager.settings.subtleReminderSize.percentage
contentView = AnyView( let view = UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { [weak self] in
UserTimerReminderView(timer: timer, sizePercentage: sizePercentage) { self?.timerEngine?.dismissReminder()
[weak self] in }
self?.timerEngine?.dismissReminder() windowManager.showReminderWindow(view, windowType: .subtle)
}
)
requiresFocus = false
} }
} }
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) { func openSettings(tab: Int = 0) {
@@ -272,8 +207,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self else { return } guard let self else { return }
SettingsWindowPresenter.shared.show( windowManager.showSettings(settingsManager: self.settingsManager, initialTab: tab)
settingsManager: self.settingsManager, initialTab: tab)
} }
} }
@@ -282,33 +216,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
guard let self else { return } guard let self else { return }
OnboardingWindowPresenter.shared.show(settingsManager: self.settingsManager) windowManager.showOnboarding(settingsManager: self.settingsManager)
} }
} }
private func handleMenuDismissal() { private func handleMenuDismissal() {
NotificationCenter.default.post(name: Notification.Name("CloseMenuBarPopover"), object: nil) 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
}
}

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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<ReminderEvent?>.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<ReminderEvent?>.Publisher {
$activeReminder
}
}

View File

@@ -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: View>(_ 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 }
}

View File

@@ -5,6 +5,7 @@
// Dependency injection container for managing service instances. // Dependency injection container for managing service instances.
// //
import Combine
import Foundation import Foundation
/// A simple dependency injection container for managing service instances. /// A simple dependency injection container for managing service instances.
@@ -45,7 +46,7 @@ final class ServiceContainer {
/// Creates a test container with injectable dependencies /// Creates a test container with injectable dependencies
/// - Parameters: /// - 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 /// - enforceModeService: The enforce mode service to use
init( init(
settingsManager: any SettingsProviding, settingsManager: any SettingsProviding,
@@ -69,6 +70,11 @@ final class ServiceContainer {
return engine return engine
} }
/// Sets a custom timer engine (useful for testing)
func setTimerEngine(_ engine: TimerEngine) {
_timerEngine = engine
}
/// Sets up smart mode services /// Sets up smart mode services
func setupSmartModeServices() { func setupSmartModeServices() {
let settings = settingsManager.settings let settings = settingsManager.settings
@@ -104,9 +110,57 @@ final class ServiceContainer {
usageTrackingService = nil 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 { static func forTesting(settings: AppSettings = .defaults) -> ServiceContainer {
// We need to create this at runtime in tests using MockSettingsManager let mockSettings = MockSettingsManager(settings: settings)
fatalError("Use init(settingsManager:) directly in tests") 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<AppSettings>.Publisher {
$settings
}
private let timerConfigKeyPaths: [TimerType: WritableKeyPath<AppSettings, TimerConfiguration>] = [
.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 }
}

View File

@@ -17,6 +17,9 @@ class TimerEngine: ObservableObject {
private let settingsProvider: any SettingsProviding private let settingsProvider: any SettingsProviding
private var sleepStartTime: Date? private var sleepStartTime: Date?
/// Time provider for deterministic testing (defaults to system time)
private let timeProvider: TimeProviding
// For enforce mode integration // For enforce mode integration
private var enforceModeService: EnforceModeService? private var enforceModeService: EnforceModeService?
@@ -25,9 +28,14 @@ class TimerEngine: ObservableObject {
private var idleService: IdleMonitoringService? private var idleService: IdleMonitoringService?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init(settingsManager: any SettingsProviding, enforceModeService: EnforceModeService? = nil) { init(
settingsManager: any SettingsProviding,
enforceModeService: EnforceModeService? = nil,
timeProvider: TimeProviding = SystemTimeProvider()
) {
self.settingsProvider = settingsManager self.settingsProvider = settingsManager
self.enforceModeService = enforceModeService ?? EnforceModeService.shared self.enforceModeService = enforceModeService ?? EnforceModeService.shared
self.timeProvider = timeProvider
Task { @MainActor in Task { @MainActor in
self.enforceModeService?.setTimerEngine(self) self.enforceModeService?.setTimerEngine(self)
@@ -300,7 +308,7 @@ class TimerEngine: ObservableObject {
guard !state.isPaused else { continue } guard !state.isPaused else { continue }
guard state.isActive else { continue } guard state.isActive else { continue }
if state.targetDate < Date() - 3.0 { if state.targetDate < timeProvider.now() - 3.0 {
skipNext(identifier: identifier) skipNext(identifier: identifier)
continue continue
} }
@@ -365,7 +373,7 @@ class TimerEngine: ObservableObject {
/// - Saves current time for elapsed calculation /// - Saves current time for elapsed calculation
/// - Pauses all active timers /// - Pauses all active timers
func handleSystemSleep() { func handleSystemSleep() {
sleepStartTime = Date() sleepStartTime = timeProvider.now()
for (id, var state) in timerStates { for (id, var state) in timerStates {
state.pauseReasons.insert(.system) state.pauseReasons.insert(.system)
state.isPaused = true state.isPaused = true
@@ -387,7 +395,7 @@ class TimerEngine: ObservableObject {
sleepStartTime = nil sleepStartTime = nil
} }
let elapsedSeconds = Int(Date().timeIntervalSince(sleepStart)) let elapsedSeconds = Int(timeProvider.now().timeIntervalSince(sleepStart))
guard elapsedSeconds >= 1 else { guard elapsedSeconds >= 1 else {
for (id, var state) in timerStates { for (id, var state) in timerStates {

View File

@@ -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: View>(_ 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)
}
}
}

View File

@@ -46,6 +46,7 @@ struct BlinkReminderView: View {
.opacity(opacity) .opacity(opacity)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(.top, screenHeight * 0.05) .padding(.top, screenHeight * 0.05)
.accessibilityIdentifier(AccessibilityIdentifiers.Reminders.blinkView)
.onAppear { .onAppear {
startAnimation() startAnimation()
} }

View File

@@ -64,6 +64,7 @@ struct LookAwayReminderView: View {
.font(.system(size: 48, weight: .bold)) .font(.system(size: 48, weight: .bold))
.foregroundColor(.white) .foregroundColor(.white)
.monospacedDigit() .monospacedDigit()
.accessibilityIdentifier(AccessibilityIdentifiers.Reminders.countdownLabel)
} }
Text("Press ESC or Space to skip") Text("Press ESC or Space to skip")
@@ -81,11 +82,13 @@ struct LookAwayReminderView: View {
.foregroundColor(.white.opacity(0.7)) .foregroundColor(.white.opacity(0.7))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityIdentifiers.Reminders.dismissButton)
.padding(30) .padding(30)
} }
Spacer() Spacer()
} }
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Reminders.lookAwayView)
.onAppear { .onAppear {
startCountdown() startCountdown()
setupKeyMonitor() setupKeyMonitor()

View File

@@ -28,6 +28,7 @@ struct PostureReminderView: View {
.offset(y: yOffset) .offset(y: yOffset)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.top, screenHeight * 0.075) .padding(.top, screenHeight * 0.075)
.accessibilityIdentifier(AccessibilityIdentifiers.Reminders.postureView)
.onAppear { .onAppear {
startAnimation() startAnimation()
} }

View File

@@ -25,8 +25,8 @@ struct EnforceModeSetupView: View {
@ObservedObject var calibrationManager = CalibrationManager.shared @ObservedObject var calibrationManager = CalibrationManager.shared
var body: some View { var body: some View {
VStack(spacing: 0) { ScrollView {
ScrollView { VStack(spacing: 0) {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: "video.fill") Image(systemName: "video.fill")
.font(.system(size: 60)) .font(.system(size: 60))
@@ -171,9 +171,12 @@ struct EnforceModeSetupView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
if calibrationManager.needsRecalibration() { if calibrationManager.needsRecalibration() {
Label("Calibration expired - recalibration recommended", systemImage: "exclamationmark.triangle.fill") Label(
.font(.caption) "Calibration expired - recalibration recommended",
.foregroundColor(.orange) systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundColor(.orange)
} else { } else {
Label("Calibration active and valid", systemImage: "checkmark.circle.fill") Label("Calibration active and valid", systemImage: "checkmark.circle.fill")
.font(.caption) .font(.caption)
@@ -191,7 +194,9 @@ struct EnforceModeSetupView: View {
}) { }) {
HStack { HStack {
Image(systemName: "target") Image(systemName: "target")
Text(calibrationManager.calibrationData.isComplete ? "Recalibrate" : "Run Calibration") Text(
calibrationManager.calibrationData.isComplete
? "Recalibrate" : "Run Calibration")
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 8) .padding(.vertical, 8)
@@ -200,7 +205,9 @@ struct EnforceModeSetupView: View {
.controlSize(.regular) .controlSize(.regular)
} }
.padding() .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) { .sheet(isPresented: $showCalibrationWindow) {
EyeTrackingCalibrationView() EyeTrackingCalibrationView()
} }
@@ -427,8 +434,11 @@ struct EnforceModeSetupView: View {
Button(action: { Button(action: {
eyeTrackingService.enableDebugLogging.toggle() eyeTrackingService.enableDebugLogging.toggle()
}) { }) {
Image(systemName: eyeTrackingService.enableDebugLogging ? "ant.circle.fill" : "ant.circle") Image(
.foregroundColor(eyeTrackingService.enableDebugLogging ? .orange : .secondary) systemName: eyeTrackingService.enableDebugLogging
? "ant.circle.fill" : "ant.circle"
)
.foregroundColor(eyeTrackingService.enableDebugLogging ? .orange : .secondary)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help("Toggle console debug logging") .help("Toggle console debug logging")
@@ -450,31 +460,45 @@ struct EnforceModeSetupView: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
if let leftRatio = eyeTrackingService.debugLeftPupilRatio, if let leftRatio = eyeTrackingService.debugLeftPupilRatio,
let rightRatio = eyeTrackingService.debugRightPupilRatio { let rightRatio = eyeTrackingService.debugRightPupilRatio
{
HStack(spacing: 16) { HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Left Pupil: \(String(format: "%.3f", leftRatio))") Text("Left Pupil: \(String(format: "%.3f", leftRatio))")
.font(.caption2) .font(.caption2)
.foregroundColor( .foregroundColor(
!trackingConstants.minPupilEnabled && !trackingConstants.maxPupilEnabled ? .secondary : !trackingConstants.minPupilEnabled
(leftRatio < trackingConstants.minPupilRatio || leftRatio > trackingConstants.maxPupilRatio) ? .orange : .green && !trackingConstants.maxPupilEnabled
? .secondary
: (leftRatio < trackingConstants.minPupilRatio
|| leftRatio > trackingConstants.maxPupilRatio)
? .orange : .green
) )
Text("Right Pupil: \(String(format: "%.3f", rightRatio))") Text("Right Pupil: \(String(format: "%.3f", rightRatio))")
.font(.caption2) .font(.caption2)
.foregroundColor( .foregroundColor(
!trackingConstants.minPupilEnabled && !trackingConstants.maxPupilEnabled ? .secondary : !trackingConstants.minPupilEnabled
(rightRatio < trackingConstants.minPupilRatio || rightRatio > trackingConstants.maxPupilRatio) ? .orange : .green && !trackingConstants.maxPupilEnabled
? .secondary
: (rightRatio < trackingConstants.minPupilRatio
|| rightRatio > trackingConstants.maxPupilRatio)
? .orange : .green
) )
} }
Spacer() Spacer()
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
Text("Range: \(String(format: "%.2f", trackingConstants.minPupilRatio)) - \(String(format: "%.2f", trackingConstants.maxPupilRatio))") Text(
.font(.caption2) "Range: \(String(format: "%.2f", trackingConstants.minPupilRatio)) - \(String(format: "%.2f", trackingConstants.maxPupilRatio))"
.foregroundColor(.secondary) )
let bothEyesOut = (leftRatio < trackingConstants.minPupilRatio || leftRatio > trackingConstants.maxPupilRatio) && .font(.caption2)
(rightRatio < trackingConstants.minPupilRatio || rightRatio > trackingConstants.maxPupilRatio) .foregroundColor(.secondary)
let bothEyesOut =
(leftRatio < trackingConstants.minPupilRatio
|| leftRatio > trackingConstants.maxPupilRatio)
&& (rightRatio < trackingConstants.minPupilRatio
|| rightRatio > trackingConstants.maxPupilRatio)
Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓") Text(bothEyesOut ? "Both Out ⚠️" : "In Range ✓")
.font(.caption2) .font(.caption2)
.foregroundColor(bothEyesOut ? .orange : .green) .foregroundColor(bothEyesOut ? .orange : .green)
@@ -487,32 +511,43 @@ struct EnforceModeSetupView: View {
} }
if let yaw = eyeTrackingService.debugYaw, if let yaw = eyeTrackingService.debugYaw,
let pitch = eyeTrackingService.debugPitch { let pitch = eyeTrackingService.debugPitch
{
HStack(spacing: 16) { HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text("Yaw: \(String(format: "%.3f", yaw))") Text("Yaw: \(String(format: "%.3f", yaw))")
.font(.caption2) .font(.caption2)
.foregroundColor( .foregroundColor(
!trackingConstants.yawEnabled ? .secondary : !trackingConstants.yawEnabled
abs(yaw) > trackingConstants.yawThreshold ? .orange : .green ? .secondary
: abs(yaw) > trackingConstants.yawThreshold
? .orange : .green
) )
Text("Pitch: \(String(format: "%.3f", pitch))") Text("Pitch: \(String(format: "%.3f", pitch))")
.font(.caption2) .font(.caption2)
.foregroundColor( .foregroundColor(
!trackingConstants.pitchUpEnabled && !trackingConstants.pitchDownEnabled ? .secondary : !trackingConstants.pitchUpEnabled
(pitch > trackingConstants.pitchUpThreshold || pitch < trackingConstants.pitchDownThreshold) ? .orange : .green && !trackingConstants.pitchDownEnabled
? .secondary
: (pitch > trackingConstants.pitchUpThreshold
|| pitch < trackingConstants.pitchDownThreshold)
? .orange : .green
) )
} }
Spacer() Spacer()
VStack(alignment: .trailing, spacing: 2) { VStack(alignment: .trailing, spacing: 2) {
Text("Yaw Max: \(String(format: "%.2f", trackingConstants.yawThreshold))") Text(
.font(.caption2) "Yaw Max: \(String(format: "%.2f", trackingConstants.yawThreshold))"
.foregroundColor(.secondary) )
Text("Pitch: \(String(format: "%.2f", trackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", trackingConstants.pitchUpThreshold))") .font(.caption2)
.font(.caption2) .foregroundColor(.secondary)
.foregroundColor(.secondary) Text(
"Pitch: \(String(format: "%.2f", trackingConstants.pitchDownThreshold)) to \(String(format: "%.2f", trackingConstants.pitchUpThreshold))"
)
.font(.caption2)
.foregroundColor(.secondary)
} }
} }
} }

View File

@@ -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 }
}

View File

@@ -30,11 +30,17 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
var saveCallCount = 0 var saveCallCount = 0
var loadCallCount = 0 var loadCallCount = 0
var resetToDefaultsCallCount = 0 var resetToDefaultsCallCount = 0
var saveImmediatelyCallCount = 0
/// Track timer configuration updates for verification
var timerConfigurationUpdates: [(TimerType, TimerConfiguration)] = []
init(settings: AppSettings = .defaults) { init(settings: AppSettings = .defaults) {
self.settings = settings self.settings = settings
} }
// MARK: - SettingsProviding conformance
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)")
@@ -47,6 +53,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
preconditionFailure("Unknown timer type: \(type)") preconditionFailure("Unknown timer type: \(type)")
} }
settings[keyPath: keyPath] = configuration settings[keyPath: keyPath] = configuration
timerConfigurationUpdates.append((type, configuration))
} }
func allTimerConfigurations() -> [TimerType: TimerConfiguration] { func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
@@ -62,7 +69,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
} }
func saveImmediately() { func saveImmediately() {
saveCallCount += 1 saveImmediatelyCallCount += 1
} }
func load() { func load() {
@@ -73,4 +80,81 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
resetToDefaultsCallCount += 1 resetToDefaultsCallCount += 1
settings = .defaults 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 = []
}
} }

View File

@@ -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: View>(_ 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) }
}
}

View File

@@ -12,25 +12,23 @@ import XCTest
final class TimerEngineTests: XCTestCase { final class TimerEngineTests: XCTestCase {
var timerEngine: TimerEngine! var timerEngine: TimerEngine!
var settingsManager: SettingsManager! var mockSettings: MockSettingsManager!
override func setUp() async throws { override func setUp() async throws {
try await super.setUp() try await super.setUp()
settingsManager = SettingsManager.shared mockSettings = MockSettingsManager()
UserDefaults.standard.removeObject(forKey: "gazeAppSettings") timerEngine = TimerEngine(settingsManager: mockSettings, enforceModeService: nil)
settingsManager.load()
timerEngine = TimerEngine(settingsManager: settingsManager)
} }
override func tearDown() async throws { override func tearDown() async throws {
timerEngine.stop() timerEngine.stop()
UserDefaults.standard.removeObject(forKey: "gazeAppSettings") mockSettings = nil
try await super.tearDown() try await super.tearDown()
} }
func testTimerInitialization() { func testTimerInitialization() {
// Enable all timers for this test (blink is disabled by default) // Enable all timers for this test (blink is disabled by default)
settingsManager.settings.blinkTimer.enabled = true mockSettings.enableTimer(.blink)
timerEngine.start() timerEngine.start()
XCTAssertEqual(timerEngine.timerStates.count, 3) XCTAssertEqual(timerEngine.timerStates.count, 3)
@@ -60,7 +58,7 @@ final class TimerEngineTests: XCTestCase {
} }
func testPauseAllTimers() { func testPauseAllTimers() {
settingsManager.settings.blinkTimer.enabled = true mockSettings.enableTimer(.blink)
timerEngine.start() timerEngine.start()
timerEngine.pause() timerEngine.pause()
@@ -70,7 +68,7 @@ final class TimerEngineTests: XCTestCase {
} }
func testResumeAllTimers() { func testResumeAllTimers() {
settingsManager.settings.blinkTimer.enabled = true mockSettings.enableTimer(.blink)
timerEngine.start() timerEngine.start()
timerEngine.pause() timerEngine.pause()
timerEngine.resume() timerEngine.resume()
@@ -81,7 +79,7 @@ final class TimerEngineTests: XCTestCase {
} }
func testSkipNext() { func testSkipNext() {
settingsManager.settings.lookAwayTimer.intervalSeconds = 60 mockSettings.setTimerInterval(.lookAway, seconds: 60)
timerEngine.start() timerEngine.start()
timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds = 10 timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds = 10
@@ -123,8 +121,8 @@ final class TimerEngineTests: XCTestCase {
} }
func testDismissReminderResetsTimer() { func testDismissReminderResetsTimer() {
settingsManager.settings.blinkTimer.enabled = true mockSettings.enableTimer(.blink)
settingsManager.settings.blinkTimer.intervalSeconds = 7 * 60 mockSettings.setTimerInterval(.blink, seconds: 7 * 60)
timerEngine.start() timerEngine.start()
timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds = 0 timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds = 0
timerEngine.activeReminder = .blinkTriggered timerEngine.activeReminder = .blinkTriggered
@@ -156,7 +154,7 @@ final class TimerEngineTests: XCTestCase {
XCTAssertNotNil(timerEngine.activeReminder) XCTAssertNotNil(timerEngine.activeReminder)
if case .lookAwayTriggered(let countdown) = timerEngine.activeReminder { if case .lookAwayTriggered(let countdown) = timerEngine.activeReminder {
XCTAssertEqual(countdown, settingsManager.settings.lookAwayCountdownSeconds) XCTAssertEqual(countdown, mockSettings.settings.lookAwayCountdownSeconds)
} else { } else {
XCTFail("Expected lookAwayTriggered reminder") XCTFail("Expected lookAwayTriggered reminder")
} }
@@ -166,7 +164,7 @@ final class TimerEngineTests: XCTestCase {
} }
func testTriggerReminderForBlink() { func testTriggerReminderForBlink() {
settingsManager.settings.blinkTimer.enabled = true mockSettings.enableTimer(.blink)
timerEngine.start() timerEngine.start()
timerEngine.triggerReminder(for: .builtIn(.blink)) timerEngine.triggerReminder(for: .builtIn(.blink))
@@ -260,7 +258,7 @@ final class TimerEngineTests: XCTestCase {
} }
func testDismissBlinkReminderResumesTimer() { func testDismissBlinkReminderResumesTimer() {
settingsManager.settings.blinkTimer.enabled = true mockSettings.enableTimer(.blink)
timerEngine.start() timerEngine.start()
timerEngine.triggerReminder(for: .builtIn(.blink)) timerEngine.triggerReminder(for: .builtIn(.blink))
@@ -281,9 +279,9 @@ final class TimerEngineTests: XCTestCase {
} }
func testAllTimersStartWhenEnabled() { func testAllTimersStartWhenEnabled() {
settingsManager.settings.lookAwayTimer.enabled = true mockSettings.enableTimer(.lookAway)
settingsManager.settings.blinkTimer.enabled = true mockSettings.enableTimer(.blink)
settingsManager.settings.postureTimer.enabled = true mockSettings.enableTimer(.posture)
timerEngine.start() timerEngine.start()
@@ -294,9 +292,9 @@ final class TimerEngineTests: XCTestCase {
} }
func testAllTimersDisabled() { func testAllTimersDisabled() {
settingsManager.settings.lookAwayTimer.enabled = false mockSettings.disableTimer(.lookAway)
settingsManager.settings.blinkTimer.enabled = false mockSettings.disableTimer(.blink)
settingsManager.settings.postureTimer.enabled = false mockSettings.disableTimer(.posture)
timerEngine.start() timerEngine.start()
@@ -304,9 +302,9 @@ final class TimerEngineTests: XCTestCase {
} }
func testPartialTimersEnabled() { func testPartialTimersEnabled() {
settingsManager.settings.lookAwayTimer.enabled = true mockSettings.enableTimer(.lookAway)
settingsManager.settings.blinkTimer.enabled = false mockSettings.disableTimer(.blink)
settingsManager.settings.postureTimer.enabled = true mockSettings.enableTimer(.posture)
timerEngine.start() timerEngine.start()
@@ -325,7 +323,7 @@ final class TimerEngineTests: XCTestCase {
intervalMinutes: 1, intervalMinutes: 1,
message: "Drink water" message: "Drink water"
) )
settingsManager.settings.userTimers = [overlayTimer] mockSettings.addUserTimer(overlayTimer)
timerEngine.start() timerEngine.start()
@@ -345,7 +343,6 @@ final class TimerEngineTests: XCTestCase {
XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id)))
// Now trigger a subtle reminder (blink) while overlay is still active // Now trigger a subtle reminder (blink) while overlay is still active
let previousActiveReminder = timerEngine.activeReminder
timerEngine.triggerReminder(for: .builtIn(.blink)) timerEngine.triggerReminder(for: .builtIn(.blink))
// The activeReminder should be replaced with the blink reminder // 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) // Both timers should be paused (the one that triggered their reminder)
XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id)))
XCTAssertTrue(timerEngine.isTimerPaused(.builtIn(.blink))) 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() { 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 // Setup overlay user timer
let overlayTimer = UserTimer( let overlayTimer = UserTimer(
title: "Stand Up", title: "Stand Up",
@@ -377,9 +367,9 @@ final class TimerEngineTests: XCTestCase {
timeOnScreenSeconds: 10, timeOnScreenSeconds: 10,
intervalMinutes: 1 intervalMinutes: 1
) )
settingsManager.settings.userTimers = [overlayTimer] mockSettings.addUserTimer(overlayTimer)
settingsManager.settings.blinkTimer.enabled = true mockSettings.enableTimer(.blink)
settingsManager.settings.blinkTimer.intervalSeconds = 60 mockSettings.setTimerInterval(.blink, seconds: 60)
timerEngine.start() timerEngine.start()
@@ -412,9 +402,53 @@ final class TimerEngineTests: XCTestCase {
XCTAssertFalse(timerEngine.isTimerPaused(.builtIn(.blink))) XCTAssertFalse(timerEngine.isTimerPaused(.builtIn(.blink)))
XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 60) XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 60)
// The overlay timer should still be paused (user needs to dismiss it manually) // The overlay timer should still be paused
// Note: In the actual app, AppDelegate tracks this window separately and it
// remains visible even after the subtle reminder dismisses
XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id))) 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()
}
} }