266 lines
10 KiB
Swift
266 lines
10 KiB
Swift
// ── 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"))
|
|
}
|
|
}
|