general: testability enhancements

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

View File

@@ -30,11 +30,17 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
var saveCallCount = 0
var loadCallCount = 0
var resetToDefaultsCallCount = 0
var saveImmediatelyCallCount = 0
/// Track timer configuration updates for verification
var timerConfigurationUpdates: [(TimerType, TimerConfiguration)] = []
init(settings: AppSettings = .defaults) {
self.settings = settings
}
// MARK: - SettingsProviding conformance
func timerConfiguration(for type: TimerType) -> TimerConfiguration {
guard let keyPath = timerConfigKeyPaths[type] else {
preconditionFailure("Unknown timer type: \(type)")
@@ -47,6 +53,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
preconditionFailure("Unknown timer type: \(type)")
}
settings[keyPath: keyPath] = configuration
timerConfigurationUpdates.append((type, configuration))
}
func allTimerConfigurations() -> [TimerType: TimerConfiguration] {
@@ -62,7 +69,7 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
}
func saveImmediately() {
saveCallCount += 1
saveImmediatelyCallCount += 1
}
func load() {
@@ -73,4 +80,81 @@ final class MockSettingsManager: ObservableObject, SettingsProviding {
resetToDefaultsCallCount += 1
settings = .defaults
}
// MARK: - Test helper methods
/// Resets all call tracking counters
func resetCallTracking() {
saveCallCount = 0
loadCallCount = 0
resetToDefaultsCallCount = 0
saveImmediatelyCallCount = 0
timerConfigurationUpdates = []
}
/// Creates settings with all timers enabled
static func withAllTimersEnabled() -> MockSettingsManager {
var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = true
settings.blinkTimer.enabled = true
settings.postureTimer.enabled = true
return MockSettingsManager(settings: settings)
}
/// Creates settings with all timers disabled
static func withAllTimersDisabled() -> MockSettingsManager {
var settings = AppSettings.defaults
settings.lookAwayTimer.enabled = false
settings.blinkTimer.enabled = false
settings.postureTimer.enabled = false
return MockSettingsManager(settings: settings)
}
/// Creates settings with onboarding completed
static func withOnboardingCompleted() -> MockSettingsManager {
var settings = AppSettings.defaults
settings.hasCompletedOnboarding = true
return MockSettingsManager(settings: settings)
}
/// Creates settings with custom timer intervals (in seconds)
static func withTimerIntervals(
lookAway: Int = 20 * 60,
blink: Int = 7 * 60,
posture: Int = 30 * 60
) -> MockSettingsManager {
var settings = AppSettings.defaults
settings.lookAwayTimer.intervalSeconds = lookAway
settings.blinkTimer.intervalSeconds = blink
settings.postureTimer.intervalSeconds = posture
return MockSettingsManager(settings: settings)
}
/// Enables a specific timer
func enableTimer(_ type: TimerType) {
guard let keyPath = timerConfigKeyPaths[type] else { return }
settings[keyPath: keyPath].enabled = true
}
/// Disables a specific timer
func disableTimer(_ type: TimerType) {
guard let keyPath = timerConfigKeyPaths[type] else { return }
settings[keyPath: keyPath].enabled = false
}
/// Sets a specific timer's interval
func setTimerInterval(_ type: TimerType, seconds: Int) {
guard let keyPath = timerConfigKeyPaths[type] else { return }
settings[keyPath: keyPath].intervalSeconds = seconds
}
/// Adds a user timer
func addUserTimer(_ timer: UserTimer) {
settings.userTimers.append(timer)
}
/// Removes all user timers
func clearUserTimers() {
settings.userTimers = []
}
}

View File

@@ -0,0 +1,101 @@
//
// MockWindowManager.swift
// GazeTests
//
// A mock implementation of WindowManaging for isolated unit testing.
//
import SwiftUI
@testable import Gaze
/// A mock implementation of WindowManaging that doesn't create real windows.
/// This allows tests to run in complete isolation without affecting the UI.
@MainActor
final class MockWindowManager: WindowManaging {
// MARK: - State tracking
var isOverlayReminderVisible: Bool = false
var isSubtleReminderVisible: Bool = false
// MARK: - Call tracking for verification
var showReminderWindowCalls: [(windowType: ReminderWindowType, viewType: String)] = []
var dismissOverlayReminderCallCount = 0
var dismissSubtleReminderCallCount = 0
var dismissAllRemindersCallCount = 0
var showSettingsCalls: [Int] = []
var showOnboardingCallCount = 0
/// The last window type shown
var lastShownWindowType: ReminderWindowType?
// MARK: - WindowManaging conformance
func showReminderWindow<Content: View>(_ content: Content, windowType: ReminderWindowType) {
let viewType = String(describing: type(of: content))
showReminderWindowCalls.append((windowType: windowType, viewType: viewType))
lastShownWindowType = windowType
switch windowType {
case .overlay:
isOverlayReminderVisible = true
case .subtle:
isSubtleReminderVisible = true
}
}
func dismissOverlayReminder() {
dismissOverlayReminderCallCount += 1
isOverlayReminderVisible = false
}
func dismissSubtleReminder() {
dismissSubtleReminderCallCount += 1
isSubtleReminderVisible = false
}
func dismissAllReminders() {
dismissAllRemindersCallCount += 1
isOverlayReminderVisible = false
isSubtleReminderVisible = false
}
func showSettings(settingsManager: any SettingsProviding, initialTab: Int) {
showSettingsCalls.append(initialTab)
}
func showOnboarding(settingsManager: any SettingsProviding) {
showOnboardingCallCount += 1
}
// MARK: - Test helpers
/// Resets all call tracking counters
func resetCallTracking() {
showReminderWindowCalls = []
dismissOverlayReminderCallCount = 0
dismissSubtleReminderCallCount = 0
dismissAllRemindersCallCount = 0
showSettingsCalls = []
showOnboardingCallCount = 0
lastShownWindowType = nil
isOverlayReminderVisible = false
isSubtleReminderVisible = false
}
/// Returns the number of overlay windows shown
var overlayWindowsShownCount: Int {
showReminderWindowCalls.filter { $0.windowType == .overlay }.count
}
/// Returns the number of subtle windows shown
var subtleWindowsShownCount: Int {
showReminderWindowCalls.filter { $0.windowType == .subtle }.count
}
/// Checks if a specific view type was shown
func wasViewShown(containing typeName: String) -> Bool {
showReminderWindowCalls.contains { $0.viewType.contains(typeName) }
}
}

View File

@@ -12,25 +12,23 @@ import XCTest
final class TimerEngineTests: XCTestCase {
var timerEngine: TimerEngine!
var settingsManager: SettingsManager!
var mockSettings: MockSettingsManager!
override func setUp() async throws {
try await super.setUp()
settingsManager = SettingsManager.shared
UserDefaults.standard.removeObject(forKey: "gazeAppSettings")
settingsManager.load()
timerEngine = TimerEngine(settingsManager: settingsManager)
mockSettings = MockSettingsManager()
timerEngine = TimerEngine(settingsManager: mockSettings, enforceModeService: nil)
}
override func tearDown() async throws {
timerEngine.stop()
UserDefaults.standard.removeObject(forKey: "gazeAppSettings")
mockSettings = nil
try await super.tearDown()
}
func testTimerInitialization() {
// Enable all timers for this test (blink is disabled by default)
settingsManager.settings.blinkTimer.enabled = true
mockSettings.enableTimer(.blink)
timerEngine.start()
XCTAssertEqual(timerEngine.timerStates.count, 3)
@@ -60,7 +58,7 @@ final class TimerEngineTests: XCTestCase {
}
func testPauseAllTimers() {
settingsManager.settings.blinkTimer.enabled = true
mockSettings.enableTimer(.blink)
timerEngine.start()
timerEngine.pause()
@@ -70,7 +68,7 @@ final class TimerEngineTests: XCTestCase {
}
func testResumeAllTimers() {
settingsManager.settings.blinkTimer.enabled = true
mockSettings.enableTimer(.blink)
timerEngine.start()
timerEngine.pause()
timerEngine.resume()
@@ -81,7 +79,7 @@ final class TimerEngineTests: XCTestCase {
}
func testSkipNext() {
settingsManager.settings.lookAwayTimer.intervalSeconds = 60
mockSettings.setTimerInterval(.lookAway, seconds: 60)
timerEngine.start()
timerEngine.timerStates[.builtIn(.lookAway)]?.remainingSeconds = 10
@@ -123,8 +121,8 @@ final class TimerEngineTests: XCTestCase {
}
func testDismissReminderResetsTimer() {
settingsManager.settings.blinkTimer.enabled = true
settingsManager.settings.blinkTimer.intervalSeconds = 7 * 60
mockSettings.enableTimer(.blink)
mockSettings.setTimerInterval(.blink, seconds: 7 * 60)
timerEngine.start()
timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds = 0
timerEngine.activeReminder = .blinkTriggered
@@ -156,7 +154,7 @@ final class TimerEngineTests: XCTestCase {
XCTAssertNotNil(timerEngine.activeReminder)
if case .lookAwayTriggered(let countdown) = timerEngine.activeReminder {
XCTAssertEqual(countdown, settingsManager.settings.lookAwayCountdownSeconds)
XCTAssertEqual(countdown, mockSettings.settings.lookAwayCountdownSeconds)
} else {
XCTFail("Expected lookAwayTriggered reminder")
}
@@ -166,7 +164,7 @@ final class TimerEngineTests: XCTestCase {
}
func testTriggerReminderForBlink() {
settingsManager.settings.blinkTimer.enabled = true
mockSettings.enableTimer(.blink)
timerEngine.start()
timerEngine.triggerReminder(for: .builtIn(.blink))
@@ -260,7 +258,7 @@ final class TimerEngineTests: XCTestCase {
}
func testDismissBlinkReminderResumesTimer() {
settingsManager.settings.blinkTimer.enabled = true
mockSettings.enableTimer(.blink)
timerEngine.start()
timerEngine.triggerReminder(for: .builtIn(.blink))
@@ -281,9 +279,9 @@ final class TimerEngineTests: XCTestCase {
}
func testAllTimersStartWhenEnabled() {
settingsManager.settings.lookAwayTimer.enabled = true
settingsManager.settings.blinkTimer.enabled = true
settingsManager.settings.postureTimer.enabled = true
mockSettings.enableTimer(.lookAway)
mockSettings.enableTimer(.blink)
mockSettings.enableTimer(.posture)
timerEngine.start()
@@ -294,9 +292,9 @@ final class TimerEngineTests: XCTestCase {
}
func testAllTimersDisabled() {
settingsManager.settings.lookAwayTimer.enabled = false
settingsManager.settings.blinkTimer.enabled = false
settingsManager.settings.postureTimer.enabled = false
mockSettings.disableTimer(.lookAway)
mockSettings.disableTimer(.blink)
mockSettings.disableTimer(.posture)
timerEngine.start()
@@ -304,9 +302,9 @@ final class TimerEngineTests: XCTestCase {
}
func testPartialTimersEnabled() {
settingsManager.settings.lookAwayTimer.enabled = true
settingsManager.settings.blinkTimer.enabled = false
settingsManager.settings.postureTimer.enabled = true
mockSettings.enableTimer(.lookAway)
mockSettings.disableTimer(.blink)
mockSettings.enableTimer(.posture)
timerEngine.start()
@@ -325,7 +323,7 @@ final class TimerEngineTests: XCTestCase {
intervalMinutes: 1,
message: "Drink water"
)
settingsManager.settings.userTimers = [overlayTimer]
mockSettings.addUserTimer(overlayTimer)
timerEngine.start()
@@ -345,7 +343,6 @@ final class TimerEngineTests: XCTestCase {
XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id)))
// Now trigger a subtle reminder (blink) while overlay is still active
let previousActiveReminder = timerEngine.activeReminder
timerEngine.triggerReminder(for: .builtIn(.blink))
// The activeReminder should be replaced with the blink reminder
@@ -360,16 +357,9 @@ final class TimerEngineTests: XCTestCase {
// Both timers should be paused (the one that triggered their reminder)
XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id)))
XCTAssertTrue(timerEngine.isTimerPaused(.builtIn(.blink)))
// The key insight: Even though TimerEngine only tracks one activeReminder,
// AppDelegate now tracks overlay and subtle windows separately, so both
// reminders can be displayed simultaneously without interference
}
func testOverlayReminderDoesNotBlockSubtleReminders() {
// This test verifies the fix for the bug where a subtle reminder
// would cause an overlay reminder to get stuck
// Setup overlay user timer
let overlayTimer = UserTimer(
title: "Stand Up",
@@ -377,9 +367,9 @@ final class TimerEngineTests: XCTestCase {
timeOnScreenSeconds: 10,
intervalMinutes: 1
)
settingsManager.settings.userTimers = [overlayTimer]
settingsManager.settings.blinkTimer.enabled = true
settingsManager.settings.blinkTimer.intervalSeconds = 60
mockSettings.addUserTimer(overlayTimer)
mockSettings.enableTimer(.blink)
mockSettings.setTimerInterval(.blink, seconds: 60)
timerEngine.start()
@@ -412,9 +402,53 @@ final class TimerEngineTests: XCTestCase {
XCTAssertFalse(timerEngine.isTimerPaused(.builtIn(.blink)))
XCTAssertEqual(timerEngine.timerStates[.builtIn(.blink)]?.remainingSeconds, 60)
// The overlay timer should still be paused (user needs to dismiss it manually)
// Note: In the actual app, AppDelegate tracks this window separately and it
// remains visible even after the subtle reminder dismisses
// The overlay timer should still be paused
XCTAssertTrue(timerEngine.isTimerPaused(.user(id: overlayTimer.id)))
}
// MARK: - Tests using injectable time provider
func testTimerEngineWithMockTimeProvider() {
let mockTime = MockTimeProvider(startTime: Date())
let engine = TimerEngine(
settingsManager: mockSettings,
enforceModeService: nil,
timeProvider: mockTime
)
engine.start()
XCTAssertNotNil(engine.timerStates[.builtIn(.lookAway)])
engine.stop()
}
func testSystemSleepWakeWithMockTime() {
let startDate = Date()
let mockTime = MockTimeProvider(startTime: startDate)
let engine = TimerEngine(
settingsManager: mockSettings,
enforceModeService: nil,
timeProvider: mockTime
)
engine.start()
let initialRemaining = engine.timerStates[.builtIn(.lookAway)]?.remainingSeconds ?? 0
// Simulate sleep
engine.handleSystemSleep()
XCTAssertTrue(engine.isTimerPaused(.builtIn(.lookAway)))
// Advance mock time by 5 minutes
mockTime.advance(by: 300)
// Simulate wake
engine.handleSystemWake()
// Timer should resume and have adjusted remaining time
XCTAssertFalse(engine.isTimerPaused(.builtIn(.lookAway)))
let newRemaining = engine.timerStates[.builtIn(.lookAway)]?.remainingSeconds ?? 0
XCTAssertEqual(newRemaining, initialRemaining - 300)
engine.stop()
}
}