From 0b798bf9ffb5b19239e31c85a54bb3ed1d027b29 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:01:57 -0800 Subject: [PATCH] test(shared): add SharedTimerData tests + update roadmap Week 7 checkmarks --- docs/roadmap.md | 62 ++--- .../SharedTimerDataTests.swift | 235 ++++++++++++++++++ 2 files changed, 266 insertions(+), 31 deletions(-) create mode 100644 ios/ChronoMindTests/SharedTimerDataTests.swift diff --git a/docs/roadmap.md b/docs/roadmap.md index e7d9d17..b312772 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 diff --git a/ios/ChronoMindTests/SharedTimerDataTests.swift b/ios/ChronoMindTests/SharedTimerDataTests.swift new file mode 100644 index 0000000..ef5b327 --- /dev/null +++ b/ios/ChronoMindTests/SharedTimerDataTests.swift @@ -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) + } +}