test(shared): add SharedTimerData tests + update roadmap Week 7 checkmarks
This commit is contained in:
parent
016a3d14e5
commit
0b798bf9ff
@ -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
|
||||
|
||||
235
ios/ChronoMindTests/SharedTimerDataTests.swift
Normal file
235
ios/ChronoMindTests/SharedTimerDataTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user