general: testability enhancements
This commit is contained in:
@@ -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 = []
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user