// ── Reschedule Engine Tests ─────────────────────────────────── import XCTest @testable import ChronoMind final class RescheduleEngineTests: XCTestCase { // MARK: - Shift All func testShiftAllActiveTimers() { let now = Date() let t1 = createAlarm(CreateAlarmParams(label: "Morning", targetTime: now.addingTimeInterval(3600), urgency: .standard)) let t2 = createAlarm(CreateAlarmParams(label: "Noon", targetTime: now.addingTimeInterval(7200), urgency: .important)) var t3 = createAlarm(CreateAlarmParams(label: "Done", targetTime: now.addingTimeInterval(-600), urgency: .gentle)) t3 = completeTimer(t3) let result = RescheduleEngine.apply( action: .shiftAll(interval: 1800), // 30 minutes to: [t1, t2, t3], now: now ) // Only active timers should be affected XCTAssertEqual(result.affectedTimerIds.count, 2) XCTAssertTrue(result.affectedTimerIds.contains(t1.id)) XCTAssertTrue(result.affectedTimerIds.contains(t2.id)) // Check shift amounts let shifted1 = result.updatedTimers.first { $0.id == t1.id }! XCTAssertEqual(shifted1.targetTime.timeIntervalSince(t1.targetTime), 1800, accuracy: 1) let shifted2 = result.updatedTimers.first { $0.id == t2.id }! XCTAssertEqual(shifted2.targetTime.timeIntervalSince(t2.targetTime), 1800, accuracy: 1) // Completed timer should be unchanged let unchanged = result.updatedTimers.first { $0.id == t3.id }! XCTAssertEqual(unchanged.state, .completed) } func testShiftSpecificTimers() { let now = Date() let t1 = createAlarm(CreateAlarmParams(label: "A", targetTime: now.addingTimeInterval(3600))) let t2 = createAlarm(CreateAlarmParams(label: "B", targetTime: now.addingTimeInterval(7200))) let t3 = createAlarm(CreateAlarmParams(label: "C", targetTime: now.addingTimeInterval(10800))) let result = RescheduleEngine.apply( action: .shift(timerIds: [t1.id, t3.id], interval: 900), to: [t1, t2, t3], now: now ) XCTAssertEqual(result.affectedTimerIds.count, 2) XCTAssertFalse(result.affectedTimerIds.contains(t2.id)) let shiftedA = result.updatedTimers.first { $0.id == t1.id }! XCTAssertEqual(shiftedA.targetTime.timeIntervalSince(t1.targetTime), 900, accuracy: 1) // B should be unchanged let unchangedB = result.updatedTimers.first { $0.id == t2.id }! XCTAssertEqual(unchangedB.targetTime, t2.targetTime) } // MARK: - Push All (future only) func testPushAllOnlyFutureTimers() { let now = Date() let future1 = createAlarm(CreateAlarmParams(label: "Future", targetTime: now.addingTimeInterval(3600))) let past1 = createAlarm(CreateAlarmParams(label: "Past", targetTime: now.addingTimeInterval(-100))) let result = RescheduleEngine.apply( action: .pushAll(interval: 600), to: [future1, past1], now: now ) // Only future timers with active state XCTAssertTrue(result.affectedTimerIds.contains(future1.id)) // Past timer is still active state but target is in the past XCTAssertFalse(result.affectedTimerIds.contains(past1.id)) } // MARK: - Skip func testSkipNextTimer() { let now = Date() let t1 = createAlarm(CreateAlarmParams(label: "Soon", targetTime: now.addingTimeInterval(600))) let t2 = createAlarm(CreateAlarmParams(label: "Later", targetTime: now.addingTimeInterval(3600))) let result = RescheduleEngine.apply( action: .skip(timerId: "next"), to: [t1, t2], now: now ) XCTAssertEqual(result.affectedTimerIds.count, 1) XCTAssertEqual(result.affectedTimerIds.first, t1.id) let skipped = result.updatedTimers.first { $0.id == t1.id }! XCTAssertEqual(skipped.state, .dismissed) } func testSkipSpecificTimer() { let now = Date() let t1 = createAlarm(CreateAlarmParams(label: "A", targetTime: now.addingTimeInterval(600))) let t2 = createAlarm(CreateAlarmParams(label: "B", targetTime: now.addingTimeInterval(3600))) let result = RescheduleEngine.apply( action: .skip(timerId: t2.id), to: [t1, t2], now: now ) XCTAssertEqual(result.affectedTimerIds.count, 1) XCTAssertEqual(result.affectedTimerIds.first, t2.id) } // MARK: - Undo func testUndoRestoresOriginalState() { let now = Date() let t1 = createAlarm(CreateAlarmParams(label: "Original", targetTime: now.addingTimeInterval(3600))) let originalTarget = t1.targetTime let result = RescheduleEngine.apply( action: .pushAll(interval: 1800), to: [t1], now: now ) // Timer was shifted let shifted = result.updatedTimers.first { $0.id == t1.id }! XCTAssertNotEqual(shifted.targetTime, originalTarget) // Undo let restored = RescheduleEngine.undo(result: result, in: result.updatedTimers) let restoredTimer = restored.first { $0.id == t1.id }! XCTAssertEqual(restoredTimer.targetTime, originalTarget) } // MARK: - Warning Recalculation func testShiftRecalculatesWarnings() { let now = Date() let target = now.addingTimeInterval(3600) var timer = createAlarm(CreateAlarmParams( label: "WithWarnings", targetTime: target, urgency: .important, cascade: CascadeConfig(preset: .standard, intervals: []) )) // Ensure we have warnings guard !timer.warnings.isEmpty else { // If no warnings generated, create manually timer.warnings = [ CascadeWarning(id: "w1", minutesBefore: 30, fired: true, firedAt: now.addingTimeInterval(-100), scheduledTime: target.addingTimeInterval(-1800)), CascadeWarning(id: "w2", minutesBefore: 10, fired: false, firedAt: nil, scheduledTime: target.addingTimeInterval(-600)), ] let result = RescheduleEngine.apply( action: .pushAll(interval: 1800), // push 30 min to: [timer], now: now ) let shifted = result.updatedTimers.first! // Warning times should be recalculated based on new target let newTarget = target.addingTimeInterval(1800) XCTAssertEqual(shifted.warnings[0].scheduledTime, newTarget.addingTimeInterval(-1800), "30-min warning should be at newTarget - 30m") XCTAssertEqual(shifted.warnings[1].scheduledTime, newTarget.addingTimeInterval(-600), "10-min warning should be at newTarget - 10m") // Warning that was fired but is now in the future should be reset if shifted.warnings[0].scheduledTime > now { XCTAssertFalse(shifted.warnings[0].fired, "Warning in the future should have fired reset") } return } let result = RescheduleEngine.apply( action: .pushAll(interval: 1800), to: [timer], now: now ) let shifted = result.updatedTimers.first! let newTarget = target.addingTimeInterval(1800) for warning in shifted.warnings { let expected = newTarget.addingTimeInterval(-Double(warning.minutesBefore) * 60) XCTAssertEqual(warning.scheduledTime, expected, accuracy: 1) } } // MARK: - Late Dismissal Detection func testDetectLateDismissal() { let target = Date().addingTimeInterval(-600) // fired 10 min ago var timer = createAlarm(CreateAlarmParams(label: "Late", targetTime: target)) timer = fireTimer(timer) let delay = RescheduleEngine.detectLateDismissal(timer: timer, dismissedAt: Date()) XCTAssertNotNil(delay) XCTAssertGreaterThanOrEqual(delay!, 300) // at least 5 minutes } func testNoDetectionForOnTimeDismissal() { let target = Date().addingTimeInterval(-60) // fired 1 min ago var timer = createAlarm(CreateAlarmParams(label: "OnTime", targetTime: target)) timer = fireTimer(timer) let delay = RescheduleEngine.detectLateDismissal(timer: timer, dismissedAt: Date()) XCTAssertNil(delay) // under 5 minutes threshold } // MARK: - Smart Suggestions func testSuggestionsForLateDismissal() { let target = Date().addingTimeInterval(-1800) // 30 min ago var timer = createAlarm(CreateAlarmParams(label: "Test", targetTime: target)) timer = fireTimer(timer) let suggestions = RescheduleSuggestion.suggestionsForLateDismissal(timer: timer, dismissDelay: 1800) XCTAssertFalse(suggestions.isEmpty) XCTAssertTrue(suggestions.count >= 2) // shift + skip at minimum } func testNoSuggestionsForSmallDelay() { let target = Date().addingTimeInterval(-120) // 2 min ago var timer = createAlarm(CreateAlarmParams(label: "Quick", targetTime: target)) timer = fireTimer(timer) let suggestions = RescheduleSuggestion.suggestionsForLateDismissal(timer: timer, dismissDelay: 120) XCTAssertTrue(suggestions.isEmpty) // under 5 min threshold } // MARK: - Result Description func testResultDescriptionFormat() { let now = Date() let t1 = createAlarm(CreateAlarmParams(label: "A", targetTime: now.addingTimeInterval(3600))) let result = RescheduleEngine.apply( action: .pushAll(interval: 1800), to: [t1], now: now ) XCTAssertTrue(result.description.contains("30m")) XCTAssertTrue(result.description.contains("later")) } func testNegativeShiftDescription() { let now = Date() let t1 = createAlarm(CreateAlarmParams(label: "A", targetTime: now.addingTimeInterval(3600))) let result = RescheduleEngine.apply( action: .pushAll(interval: -900), to: [t1], now: now ) XCTAssertTrue(result.description.contains("15m")) XCTAssertTrue(result.description.contains("earlier")) } }