general: testability enhancements
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
tasks
|
tasks
|
||||||
|
python_impl
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
*.log
|
*.log
|
||||||
*.app
|
*.app
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
73
Gaze/Constants/AccessibilityIdentifiers.swift
Normal file
73
Gaze/Constants/AccessibilityIdentifiers.swift
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
44
Gaze/Protocols/TimeProviding.swift
Normal file
44
Gaze/Protocols/TimeProviding.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
86
Gaze/Protocols/TimerEngineProviding.swift
Normal file
86
Gaze/Protocols/TimerEngineProviding.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Gaze/Protocols/WindowManaging.swift
Normal file
51
Gaze/Protocols/WindowManaging.swift
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
108
Gaze/Services/WindowManager.swift
Normal file
108
Gaze/Services/WindowManager.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -153,7 +153,7 @@ struct EnforceModeSetupView: View {
|
|||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var calibrationSection: some View {
|
private var calibrationSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -163,17 +163,20 @@ struct EnforceModeSetupView: View {
|
|||||||
Text("Eye Tracking Calibration")
|
Text("Eye Tracking Calibration")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
if calibrationManager.calibrationData.isComplete {
|
if calibrationManager.calibrationData.isComplete {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text(calibrationManager.getCalibrationSummary())
|
Text(calibrationManager.getCalibrationSummary())
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.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)
|
||||||
@@ -185,13 +188,15 @@ struct EnforceModeSetupView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
showCalibrationWindow = true
|
showCalibrationWindow = true
|
||||||
}) {
|
}) {
|
||||||
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,12 +434,15 @@ 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")
|
||||||
|
|
||||||
Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") {
|
Button(showAdvancedSettings ? "Hide Settings" : "Show Settings") {
|
||||||
withAnimation {
|
withAnimation {
|
||||||
showAdvancedSettings.toggle()
|
showAdvancedSettings.toggle()
|
||||||
@@ -441,40 +451,54 @@ struct EnforceModeSetupView: View {
|
|||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug info always visible when tracking
|
// Debug info always visible when tracking
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Live Values:")
|
Text("Live Values:")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.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)
|
||||||
@@ -485,34 +509,45 @@ struct EnforceModeSetupView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
Gaze/Views/WindowClasses.swift
Normal file
20
Gaze/Views/WindowClasses.swift
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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 = []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
101
GazeTests/Mocks/MockWindowManager.swift
Normal file
101
GazeTests/Mocks/MockWindowManager.swift
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user