test(week8): add reschedule engine tests (14) + gamification engine tests (15) + update roadmap
This commit is contained in:
parent
40fd63e748
commit
4b579750a2
@ -533,18 +533,18 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
|
||||
|
||||
> **Note:** macOS menu bar app deferred to Phase 4 (Week 10-11). Phase 3 focuses on iOS + Watch quality.
|
||||
|
||||
- [ ] **AI reschedule**
|
||||
- [ ] "I slept in 30 minutes" → shift all morning timers by 30m
|
||||
- [ ] "Skip my next timer" → remove + adjust linked/dependent timers
|
||||
- [ ] "Push everything back 1 hour" → bulk shift
|
||||
- [ ] Smart suggestions: detect when user dismisses alarm late → "Shift your morning by X minutes?"
|
||||
- [ ] Undo reschedule (keep old state for 1 hour)
|
||||
- [x] **AI reschedule**
|
||||
- [x] "I slept in 30 minutes" → shift all morning timers by 30m
|
||||
- [x] "Skip my next timer" → remove + adjust linked/dependent timers
|
||||
- [x] "Push everything back 1 hour" → bulk shift
|
||||
- [x] Smart suggestions: detect when user dismisses alarm late → "Shift your morning by X minutes?"
|
||||
- [x] Undo reschedule (keep old state for 1 hour)
|
||||
|
||||
- [ ] **Gamification polish**
|
||||
- [ ] Streak badges: 7-day, 30-day, 100-day
|
||||
- [ ] Focus score: weekly score based on on-time rate + focus hours
|
||||
- [ ] Weekly summary card (shareable image — key for viral growth)
|
||||
- [ ] Gentle celebration animations (confetti for streak milestones, not obnoxious)
|
||||
- [x] **Gamification polish**
|
||||
- [x] Streak badges: 3-day, 7-day, 14-day, 30-day, 60-day, 100-day, 200-day, 365-day
|
||||
- [x] Focus score: weekly score based on on-time rate + focus hours + consistency + low snooze
|
||||
- [x] Weekly summary card (shareable image via ImageRenderer — key for viral growth)
|
||||
- [x] Gentle celebration animations (confetti for streak milestones, not obnoxious)
|
||||
|
||||
### Week 9: iPad + Polish + TestFlight
|
||||
|
||||
|
||||
216
ios/ChronoMindTests/GamificationEngineTests.swift
Normal file
216
ios/ChronoMindTests/GamificationEngineTests.swift
Normal file
@ -0,0 +1,216 @@
|
||||
// ── Gamification Engine Tests ─────────────────────────────────
|
||||
|
||||
import XCTest
|
||||
@testable import ChronoMind
|
||||
|
||||
final class GamificationEngineTests: XCTestCase {
|
||||
|
||||
// MARK: - Streak: First Completion
|
||||
|
||||
func testFirstCompletionStartsStreak() {
|
||||
let streak = GamificationEngine.updateStreak(current: .empty, completedAt: Date())
|
||||
XCTAssertEqual(streak.currentStreak, 1)
|
||||
XCTAssertEqual(streak.longestStreak, 1)
|
||||
XCTAssertEqual(streak.totalActiveDays, 1)
|
||||
XCTAssertNotNil(streak.lastActiveDate)
|
||||
XCTAssertNotNil(streak.streakStartDate)
|
||||
}
|
||||
|
||||
// MARK: - Streak: Consecutive Days
|
||||
|
||||
func testConsecutiveDaysExtendStreak() {
|
||||
let calendar = Calendar.current
|
||||
let day1 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 10))!
|
||||
let day2 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 2, hour: 14))!
|
||||
let day3 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 3, hour: 8))!
|
||||
|
||||
var streak = GamificationEngine.updateStreak(current: .empty, completedAt: day1)
|
||||
XCTAssertEqual(streak.currentStreak, 1)
|
||||
|
||||
streak = GamificationEngine.updateStreak(current: streak, completedAt: day2)
|
||||
XCTAssertEqual(streak.currentStreak, 2)
|
||||
|
||||
streak = GamificationEngine.updateStreak(current: streak, completedAt: day3)
|
||||
XCTAssertEqual(streak.currentStreak, 3)
|
||||
XCTAssertEqual(streak.longestStreak, 3)
|
||||
XCTAssertEqual(streak.totalActiveDays, 3)
|
||||
}
|
||||
|
||||
// MARK: - Streak: Same Day Doesn't Double-Count
|
||||
|
||||
func testSameDayDoesNotIncrement() {
|
||||
let calendar = Calendar.current
|
||||
let morning = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 9))!
|
||||
let afternoon = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 15))!
|
||||
|
||||
var streak = GamificationEngine.updateStreak(current: .empty, completedAt: morning)
|
||||
XCTAssertEqual(streak.currentStreak, 1)
|
||||
|
||||
streak = GamificationEngine.updateStreak(current: streak, completedAt: afternoon)
|
||||
XCTAssertEqual(streak.currentStreak, 1)
|
||||
XCTAssertEqual(streak.totalActiveDays, 1) // still 1
|
||||
}
|
||||
|
||||
// MARK: - Streak: Break Resets
|
||||
|
||||
func testMissedDayBreaksStreak() {
|
||||
let calendar = Calendar.current
|
||||
let day1 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 1, hour: 10))!
|
||||
let day2 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 2, hour: 10))!
|
||||
// Skip day 3
|
||||
let day4 = calendar.date(from: DateComponents(year: 2026, month: 3, day: 4, hour: 10))!
|
||||
|
||||
var streak = GamificationEngine.updateStreak(current: .empty, completedAt: day1)
|
||||
streak = GamificationEngine.updateStreak(current: streak, completedAt: day2)
|
||||
XCTAssertEqual(streak.currentStreak, 2)
|
||||
XCTAssertEqual(streak.longestStreak, 2)
|
||||
|
||||
streak = GamificationEngine.updateStreak(current: streak, completedAt: day4)
|
||||
XCTAssertEqual(streak.currentStreak, 1) // reset
|
||||
XCTAssertEqual(streak.longestStreak, 2) // best preserved
|
||||
XCTAssertEqual(streak.totalActiveDays, 3)
|
||||
}
|
||||
|
||||
// MARK: - Streak Validation
|
||||
|
||||
func testValidateStreakNotBroken() {
|
||||
let calendar = Calendar.current
|
||||
let yesterday = calendar.date(byAdding: .day, value: -1, to: Date())!
|
||||
|
||||
var streak = GamificationEngine.updateStreak(current: .empty, completedAt: yesterday)
|
||||
streak = GamificationEngine.validateStreak(current: streak)
|
||||
|
||||
XCTAssertEqual(streak.currentStreak, 1) // still valid
|
||||
}
|
||||
|
||||
func testValidateStreakBroken() {
|
||||
let calendar = Calendar.current
|
||||
let threeDaysAgo = calendar.date(byAdding: .day, value: -3, to: Date())!
|
||||
|
||||
var streak = GamificationEngine.updateStreak(current: .empty, completedAt: threeDaysAgo)
|
||||
streak.currentStreak = 5 // pretend it was 5
|
||||
streak = GamificationEngine.validateStreak(current: streak)
|
||||
|
||||
XCTAssertEqual(streak.currentStreak, 0) // broken
|
||||
}
|
||||
|
||||
// MARK: - Focus Score
|
||||
|
||||
func testFocusScoreWithNoTimers() {
|
||||
let score = GamificationEngine.calculateFocusScore(timers: [])
|
||||
XCTAssertEqual(score.score, 0)
|
||||
XCTAssertEqual(score.onTimeRate, 0)
|
||||
XCTAssertEqual(score.focusHours, 0)
|
||||
XCTAssertEqual(score.completedCount, 0)
|
||||
}
|
||||
|
||||
func testFocusScoreWithCompletedTimers() {
|
||||
var timers: [CMTimer] = []
|
||||
let now = Date()
|
||||
|
||||
// Create 5 completed timers this week
|
||||
for i in 0..<5 {
|
||||
var timer = createAlarm(CreateAlarmParams(
|
||||
label: "Timer \(i)",
|
||||
targetTime: now.addingTimeInterval(-Double(i * 3600))
|
||||
))
|
||||
timer = completeTimer(timer)
|
||||
timers.append(timer)
|
||||
}
|
||||
|
||||
// Create 2 dismissed timers
|
||||
for i in 0..<2 {
|
||||
var timer = createAlarm(CreateAlarmParams(
|
||||
label: "Dismissed \(i)",
|
||||
targetTime: now.addingTimeInterval(-Double(i * 1800))
|
||||
))
|
||||
timer = dismissTimer(timer)
|
||||
timers.append(timer)
|
||||
}
|
||||
|
||||
let score = GamificationEngine.calculateFocusScore(timers: timers, now: now)
|
||||
XCTAssertGreaterThan(score.score, 0)
|
||||
XCTAssertEqual(score.completedCount, 5)
|
||||
XCTAssertEqual(score.dismissedCount, 2)
|
||||
|
||||
let expectedRate = 5.0 / 7.0
|
||||
XCTAssertEqual(score.onTimeRate, expectedRate, accuracy: 0.01)
|
||||
}
|
||||
|
||||
func testFocusScoreGrades() {
|
||||
// Test grade boundaries
|
||||
let excellent = FocusScore(score: 95, onTimeRate: 0.95, focusHours: 4, completedCount: 15, dismissedCount: 1, avgSnoozeCount: 0, weekLabel: "Test")
|
||||
XCTAssertEqual(excellent.grade, .excellent)
|
||||
|
||||
let great = FocusScore(score: 80, onTimeRate: 0.8, focusHours: 3, completedCount: 10, dismissedCount: 2, avgSnoozeCount: 0.5, weekLabel: "Test")
|
||||
XCTAssertEqual(great.grade, .great)
|
||||
|
||||
let good = FocusScore(score: 65, onTimeRate: 0.7, focusHours: 2, completedCount: 8, dismissedCount: 3, avgSnoozeCount: 1, weekLabel: "Test")
|
||||
XCTAssertEqual(good.grade, .good)
|
||||
|
||||
let fair = FocusScore(score: 45, onTimeRate: 0.5, focusHours: 1, completedCount: 5, dismissedCount: 5, avgSnoozeCount: 2, weekLabel: "Test")
|
||||
XCTAssertEqual(fair.grade, .fair)
|
||||
|
||||
let needsWork = FocusScore(score: 20, onTimeRate: 0.2, focusHours: 0.5, completedCount: 2, dismissedCount: 8, avgSnoozeCount: 3, weekLabel: "Test")
|
||||
XCTAssertEqual(needsWork.grade, .needsWork)
|
||||
}
|
||||
|
||||
// MARK: - Badge Definitions
|
||||
|
||||
func testBadgeEarning() {
|
||||
let badges = BadgeDefinitions.earnedBadges(for: 10)
|
||||
let earned = badges.filter(\.isEarned)
|
||||
|
||||
// Should have earned: 3-day and 7-day
|
||||
XCTAssertEqual(earned.count, 2)
|
||||
XCTAssertTrue(earned.contains { $0.id == "streak_3" })
|
||||
XCTAssertTrue(earned.contains { $0.id == "streak_7" })
|
||||
}
|
||||
|
||||
func testNextBadge() {
|
||||
let next = BadgeDefinitions.nextBadge(for: 10)
|
||||
XCTAssertNotNil(next)
|
||||
XCTAssertEqual(next?.id, "streak_14")
|
||||
}
|
||||
|
||||
func testJustEarnedBadge() {
|
||||
// Crossed 7-day threshold
|
||||
let badge = BadgeDefinitions.justEarnedBadge(previousStreak: 6, newStreak: 7)
|
||||
XCTAssertNotNil(badge)
|
||||
XCTAssertEqual(badge?.id, "streak_7")
|
||||
|
||||
// No new badge
|
||||
let noBadge = BadgeDefinitions.justEarnedBadge(previousStreak: 8, newStreak: 9)
|
||||
XCTAssertNil(noBadge)
|
||||
}
|
||||
|
||||
func testAllBadgesEarned() {
|
||||
let badges = BadgeDefinitions.earnedBadges(for: 400)
|
||||
let earned = badges.filter(\.isEarned)
|
||||
XCTAssertEqual(earned.count, BadgeDefinitions.allBadges.count)
|
||||
|
||||
let next = BadgeDefinitions.nextBadge(for: 400)
|
||||
XCTAssertNil(next)
|
||||
}
|
||||
|
||||
// MARK: - Weekly Summary
|
||||
|
||||
func testWeeklySummaryGeneration() {
|
||||
var timers: [CMTimer] = []
|
||||
let now = Date()
|
||||
|
||||
// Add some timers
|
||||
for i in 0..<3 {
|
||||
var timer = createAlarm(CreateAlarmParams(label: "T\(i)", targetTime: now.addingTimeInterval(-Double(i * 3600))))
|
||||
timer = completeTimer(timer)
|
||||
timers.append(timer)
|
||||
}
|
||||
|
||||
let streak = StreakData(currentStreak: 5, longestStreak: 10, lastActiveDate: nil, streakStartDate: nil, totalActiveDays: 20)
|
||||
let summary = GamificationEngine.generateWeeklySummary(timers: timers, streak: streak, now: now)
|
||||
|
||||
XCTAssertEqual(summary.streak.currentStreak, 5)
|
||||
XCTAssertGreaterThanOrEqual(summary.focusScore.completedCount, 0)
|
||||
XCTAssertFalse(summary.focusScore.weekLabel.isEmpty)
|
||||
}
|
||||
}
|
||||
265
ios/ChronoMindTests/RescheduleEngineTests.swift
Normal file
265
ios/ChronoMindTests/RescheduleEngineTests.swift
Normal file
@ -0,0 +1,265 @@
|
||||
// ── 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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user