general: basic cleanup

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

View File

@@ -28,11 +28,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
self.windowManager = WindowManager.shared
super.init()
// Setup window close observers
setupWindowCloseObservers()
}
/// Initializer for testing with injectable dependencies
init(serviceContainer: ServiceContainer, windowManager: WindowManaging) {
self.serviceContainer = serviceContainer
self.windowManager = windowManager
@@ -40,16 +37,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
}
func applicationDidFinishLaunching(_ notification: Notification) {
// Set activation policy to hide dock icon
NSApplication.shared.setActivationPolicy(.accessory)
logInfo("🚀 Application did finish launching")
timerEngine = serviceContainer.timerEngine
serviceContainer.setupSmartModeServices()
// Initialize update manager after onboarding is complete
if settingsManager.settings.hasCompletedOnboarding {
updateManager = UpdateManager.shared
@@ -62,24 +55,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
if settingsManager.settings.hasCompletedOnboarding {
startTimers()
}
// DEBUG: Auto-start eye tracking test mode if launch argument is present
#if DEBUG
if CommandLine.arguments.contains("--debug-eye-tracking") {
NSLog("🔬 DEBUG: Auto-starting eye tracking test mode")
Task { @MainActor in
// Enable enforce mode if not already
if !settingsManager.settings.enforcementMode {
settingsManager.settings.enforcementMode = true
}
// Start test mode after a brief delay
try? await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
NSLog("🔬 DEBUG: Starting test mode now...")
await EnforceModeService.shared.startTestMode()
NSLog("🔬 DEBUG: Test mode started")
}
}
#endif
}
// Note: Smart mode setup is now handled by ServiceContainer
@@ -105,7 +80,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
func onboardingCompleted() {
startTimers()
// Start update checks after onboarding
if updateManager == nil {
updateManager = UpdateManager.shared
}
@@ -249,24 +223,4 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject {
}
}
private func setupWindowCloseObservers() {
NotificationCenter.default.addObserver(
self,
selector: #selector(settingsWindowDidClose),
name: Notification.Name("SettingsWindowDidClose"),
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(onboardingWindowDidClose),
name: Notification.Name("OnboardingWindowDidClose"),
object: nil
)
}
@objc private func settingsWindowDidClose() {}
@objc private func onboardingWindowDidClose() {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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