test(shared): add SharedTimerData tests + update roadmap Week 7 checkmarks

This commit is contained in:
saravanakumardb1 2026-02-27 22:01:57 -08:00
parent 016a3d14e5
commit 0b798bf9ff
2 changed files with 266 additions and 31 deletions

View File

@ -484,44 +484,44 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
### Week 7: Apple Watch + Widgets
- [ ] **Apple Watch app (`ChronoMindWatch/`)**
- [ ] `WatchTimelineView` — compact timeline for wrist
- [ ] `WatchTimerDetailView` — current timer with countdown ring
- [ ] `WatchQuickTimerView` — one-tap presets on Watch
- [x] **Apple Watch app (`ChronoMindWatch/`)**
- [x] `WatchTimelineView` — compact timeline for wrist
- [x] `WatchTimerDetailView` — current timer with countdown ring
- [x] `WatchQuickTimerView` — one-tap presets on Watch
- [ ] Digital Crown: scroll through upcoming timers
- [ ] Haptic pre-warnings on wrist (gentle tap for early warnings, prominent for final)
- [x] Haptic pre-warnings on wrist (gentle tap for early warnings, prominent for final)
- [ ] Dismiss/snooze directly from Watch
- [ ] **Watch complications**
- [ ] `WidgetKit` complications for Watch faces
- [ ] Circular: countdown to next timer
- [ ] Rectangular: next timer label + time remaining
- [ ] Inline: "Standup in 14m"
- [ ] Corner: countdown ring
- [ ] Update complications via `WidgetCenter.shared.reloadAllTimelines()`
- [x] **Watch complications**
- [x] `WidgetKit` complications for Watch faces
- [x] Circular: countdown to next timer
- [x] Rectangular: next timer label + time remaining
- [x] Inline: "Standup in 14m"
- [x] Corner: countdown ring
- [x] Update complications via `WidgetCenter.shared.reloadAllTimelines()`
- [ ] **Live Activities + Dynamic Island**
- [ ] `ActivityKit` Live Activity for active countdown/Pomodoro
- [ ] Dynamic Island compact: timer name + countdown
- [ ] Dynamic Island expanded: countdown ring + snooze/dismiss buttons
- [ ] Lock Screen Live Activity: full countdown with cascade progress
- [ ] Auto-start Live Activity when timer enters final 30 minutes
- [ ] End Live Activity when timer is dismissed
- [x] **Live Activities + Dynamic Island**
- [x] `ActivityKit` Live Activity for active countdown/Pomodoro
- [x] Dynamic Island compact: timer name + countdown
- [x] Dynamic Island expanded: countdown ring + snooze/dismiss buttons
- [x] Lock Screen Live Activity: full countdown with cascade progress
- [x] Auto-start Live Activity when timer enters final 30 minutes
- [x] End Live Activity when timer is dismissed
- [ ] **iOS widgets (`WidgetKit`)**
- [ ] Small widget: next timer countdown
- [ ] Medium widget: next 3 timers list
- [x] **iOS widgets (`WidgetKit`)**
- [x] Small widget: next timer countdown
- [x] Medium widget: next 3 timers list
- [ ] Large widget: mini timeline
- [ ] Lock Screen widget: next timer inline
- [ ] Widget tap → deep link to timer in app
- [ ] Timeline provider: update every 5 minutes + on timer change
- [x] Lock Screen widget: next timer inline
- [x] Widget tap → deep link to timer in app
- [x] Timeline provider: update every 5 minutes + on timer change
- [ ] **Siri Shortcuts**
- [ ] "Set a timer for 25 minutes" → creates countdown
- [x] **Siri Shortcuts**
- [x] "Set a timer for 25 minutes" → creates countdown
- [ ] "Start my morning routine" → starts saved routine
- [ ] "What's my next timer?" → speaks next timer info
- [ ] App Intents framework for iOS 17+
- [ ] Shortcuts app integration
- [x] "What's my next timer?" → speaks next timer info
- [x] App Intents framework for iOS 17+
- [x] Shortcuts app integration
### Week 8: AI Reschedule + Gamification
@ -550,7 +550,7 @@ ChronoMind ships in **5 phases over ~6 months**, from web MVP to full cross-plat
- [ ] **Cross-device continuity**
- [ ] Handoff: start timer on Mac, see it on iPhone and Watch
- [ ] iCloud sync for timer data (CloudKit)
- [ ] Shared UserDefaults via App Groups (iPhone ↔ Watch ↔ Widget)
- [x] Shared UserDefaults via App Groups (iPhone ↔ Watch ↔ Widget)
- [ ] **Accessibility**
- [ ] VoiceOver: all screens navigable, timer states announced

View File

@ -0,0 +1,235 @@
// SharedTimerData Tests
import XCTest
@testable import ChronoMind
final class SharedTimerDataTests: XCTestCase {
// MARK: - TimerSnapshot Conversion
func testCMTimerToSnapshot() {
let now = Date()
let target = now.addingTimeInterval(3600)
let warning = CascadeWarning(
id: "w1",
minutesBefore: 10,
fired: true,
firedAt: now.addingTimeInterval(-600),
scheduledTime: now.addingTimeInterval(-600)
)
let warning2 = CascadeWarning(
id: "w2",
minutesBefore: 5,
fired: false,
firedAt: nil,
scheduledTime: target.addingTimeInterval(-300)
)
var timer = createAlarm(CreateAlarmParams(
label: "Test Alarm",
targetTime: target,
urgency: .important
))
timer.warnings = [warning, warning2]
timer.pomodoroConfig = PomodoroConfig(workMinutes: 25, breakMinutes: 5, rounds: 4)
timer.pomodoroState = PomodoroState(currentRound: 2, isBreak: false, roundStartedAt: now)
let snapshot = timer.toSnapshot()
XCTAssertEqual(snapshot.id, timer.id)
XCTAssertEqual(snapshot.label, "Test Alarm")
XCTAssertEqual(snapshot.type, .alarm)
XCTAssertEqual(snapshot.urgency, .important)
XCTAssertEqual(snapshot.state, .active)
XCTAssertEqual(snapshot.targetTime, target)
XCTAssertEqual(snapshot.totalWarnings, 2)
XCTAssertEqual(snapshot.firedWarnings, 1)
XCTAssertEqual(snapshot.nextWarningTime, target.addingTimeInterval(-300))
XCTAssertEqual(snapshot.pomodoroCurrentRound, 2)
XCTAssertEqual(snapshot.pomodoroTotalRounds, 4)
XCTAssertEqual(snapshot.pomodoroIsBreak, false)
}
func testSnapshotWithNoWarnings() {
let now = Date()
let target = now.addingTimeInterval(600)
let timer = createCountdown(CreateCountdownParams(
label: "Quick",
duration: 600,
urgency: .gentle
))
let snapshot = timer.toSnapshot()
XCTAssertEqual(snapshot.label, "Quick")
XCTAssertEqual(snapshot.type, .countdown)
XCTAssertEqual(snapshot.urgency, .gentle)
XCTAssertNil(snapshot.pomodoroCurrentRound)
XCTAssertNil(snapshot.pomodoroTotalRounds)
}
// MARK: - TimerSnapshot Helpers
func testRemainingSeconds() {
let target = Date().addingTimeInterval(120)
let snapshot = TimerSnapshot(
id: "t1", label: "Test", type: .countdown, urgency: .standard,
state: .active, targetTime: target, duration: 120,
startedAt: Date(), elapsedBeforePause: 0, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 0, firedWarnings: 0
)
let remaining = snapshot.remainingSeconds()
XCTAssertGreaterThan(remaining, 118)
XCTAssertLessThanOrEqual(remaining, 120)
}
func testRemainingSecondsPaused() {
let target = Date().addingTimeInterval(300)
let snapshot = TimerSnapshot(
id: "t2", label: "Paused", type: .countdown, urgency: .standard,
state: .paused, targetTime: target, duration: 600,
startedAt: Date(), elapsedBeforePause: 200, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 0, firedWarnings: 0
)
// When paused, remaining = duration - elapsedBeforePause
let remaining = snapshot.remainingSeconds()
XCTAssertEqual(remaining, 400)
}
func testHasFired() {
let pastTarget = Date().addingTimeInterval(-10)
let snapshot = TimerSnapshot(
id: "t3", label: "Fired", type: .alarm, urgency: .critical,
state: .firing, targetTime: pastTarget, duration: nil,
startedAt: nil, elapsedBeforePause: 0, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 0, firedWarnings: 0
)
XCTAssertTrue(snapshot.hasFired())
}
func testHasNotFired() {
let futureTarget = Date().addingTimeInterval(3600)
let snapshot = TimerSnapshot(
id: "t4", label: "Active", type: .alarm, urgency: .standard,
state: .active, targetTime: futureTarget, duration: nil,
startedAt: nil, elapsedBeforePause: 0, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 0, firedWarnings: 0
)
XCTAssertFalse(snapshot.hasFired())
}
func testRemainingCompact() {
let target = Date().addingTimeInterval(3600)
let snapshot = TimerSnapshot(
id: "t5", label: "Hour", type: .countdown, urgency: .standard,
state: .active, targetTime: target, duration: 3600,
startedAt: Date(), elapsedBeforePause: 0, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 0, firedWarnings: 0
)
let compact = snapshot.remainingCompact()
// Should be something like "59m" or "1h"
XCTAssertFalse(compact.isEmpty)
}
// MARK: - Snapshot Codable
func testSnapshotEncodeDecode() throws {
let snapshot = TimerSnapshot(
id: "codec-test", label: "Encode Me", type: .pomodoro, urgency: .critical,
state: .warning, targetTime: Date().addingTimeInterval(500), duration: 1500,
startedAt: Date(), elapsedBeforePause: 100, snoozeCount: 1,
category: "work", pomodoroCurrentRound: 3, pomodoroTotalRounds: 4,
pomodoroIsBreak: false, nextWarningTime: Date().addingTimeInterval(200),
totalWarnings: 3, firedWarnings: 2
)
let encoder = JSONEncoder()
let data = try encoder.encode(snapshot)
let decoder = JSONDecoder()
let decoded = try decoder.decode(TimerSnapshot.self, from: data)
XCTAssertEqual(decoded.id, "codec-test")
XCTAssertEqual(decoded.label, "Encode Me")
XCTAssertEqual(decoded.type, .pomodoro)
XCTAssertEqual(decoded.urgency, .critical)
XCTAssertEqual(decoded.state, .warning)
XCTAssertEqual(decoded.duration, 1500)
XCTAssertEqual(decoded.elapsedBeforePause, 100)
XCTAssertEqual(decoded.snoozeCount, 1)
XCTAssertEqual(decoded.category, "work")
XCTAssertEqual(decoded.pomodoroCurrentRound, 3)
XCTAssertEqual(decoded.pomodoroTotalRounds, 4)
XCTAssertEqual(decoded.pomodoroIsBreak, false)
XCTAssertEqual(decoded.totalWarnings, 3)
XCTAssertEqual(decoded.firedWarnings, 2)
}
func testSnapshotArrayEncodeDecode() throws {
let snapshots = [
TimerSnapshot(
id: "a1", label: "Timer A", type: .alarm, urgency: .important,
state: .active, targetTime: Date().addingTimeInterval(1000), duration: nil,
startedAt: nil, elapsedBeforePause: 0, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 0, firedWarnings: 0
),
TimerSnapshot(
id: "a2", label: "Timer B", type: .countdown, urgency: .gentle,
state: .paused, targetTime: Date().addingTimeInterval(2000), duration: 2000,
startedAt: Date(), elapsedBeforePause: 500, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 1, firedWarnings: 0
),
]
let data = try JSONEncoder().encode(snapshots)
let decoded = try JSONDecoder().decode([TimerSnapshot].self, from: data)
XCTAssertEqual(decoded.count, 2)
XCTAssertEqual(decoded[0].id, "a1")
XCTAssertEqual(decoded[1].id, "a2")
XCTAssertEqual(decoded[1].state, .paused)
}
// MARK: - Equatable
func testSnapshotEquatable() {
let s1 = TimerSnapshot(
id: "eq1", label: "A", type: .alarm, urgency: .standard,
state: .active, targetTime: Date(), duration: nil,
startedAt: nil, elapsedBeforePause: 0, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 0, firedWarnings: 0
)
let s2 = TimerSnapshot(
id: "eq1", label: "B", type: .countdown, urgency: .critical,
state: .firing, targetTime: Date(), duration: 100,
startedAt: nil, elapsedBeforePause: 0, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 0, firedWarnings: 0
)
let s3 = TimerSnapshot(
id: "eq2", label: "A", type: .alarm, urgency: .standard,
state: .active, targetTime: Date(), duration: nil,
startedAt: nil, elapsedBeforePause: 0, snoozeCount: 0,
category: nil, pomodoroCurrentRound: nil, pomodoroTotalRounds: nil,
pomodoroIsBreak: nil, nextWarningTime: nil, totalWarnings: 0, firedWarnings: 0
)
// Same id = equal
XCTAssertEqual(s1, s2)
// Different id = not equal
XCTAssertNotEqual(s1, s3)
}
}