Skip to main content

arct_core/
stats.rs

1//! User statistics and progress tracking
2//!
3//! This module tracks user progress, maintains learning streaks, and
4//! manages achievement unlocking logic.
5
6use crate::achievement::{Achievement, UnlockCondition, UserAchievements, all_achievements};
7use crate::lesson::{Difficulty, LessonLibrary, Track};
8use chrono::{DateTime, Datelike, Local, NaiveDate, Timelike, Utc, Weekday};
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::sync::OnceLock;
12
13/// Lesson counts per difficulty for the built-in lesson library, computed once.
14///
15/// Used by the no-argument achievement APIs; callers that load user lessons
16/// should prefer the `*_with_library` variants for accurate totals.
17fn builtin_difficulty_counts() -> &'static HashMap<Difficulty, usize> {
18    static COUNTS: OnceLock<HashMap<Difficulty, usize>> = OnceLock::new();
19    COUNTS.get_or_init(|| LessonLibrary::new().difficulty_counts())
20}
21
22/// Comprehensive user statistics and progress tracking
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct UserStats {
25    /// Set of completed lesson IDs
26    pub lessons_completed: HashSet<String>,
27    /// Count of lessons completed by difficulty level
28    pub lessons_by_difficulty: HashMap<Difficulty, usize>,
29    /// Set of unique commands that have been used
30    pub commands_used: HashSet<String>,
31    /// Total number of commands executed (including duplicates)
32    pub total_commands_executed: usize,
33    /// Current consecutive days streak
34    pub current_streak: usize,
35    /// Longest streak ever achieved
36    pub longest_streak: usize,
37    /// Last date the user was active
38    pub last_active_date: NaiveDate,
39    /// Total time spent in the app (seconds)
40    pub total_time_seconds: u64,
41    /// When the current session started
42    #[serde(skip)]
43    pub session_start: DateTime<Utc>,
44    /// User's achievement progress
45    pub achievements: UserAchievements,
46    /// Completed challenges (for exploration/challenge achievements)
47    pub completed_challenges: HashSet<String>,
48    /// Lessons finished in a run where the answer was never revealed.
49    /// `#[serde(default)]` keeps older save files loadable.
50    #[serde(default)]
51    pub lessons_mastered: HashSet<String>,
52}
53
54/// Days a learner can take off between learning days without ending
55/// their streak. A missed day is a rest day, not a failure.
56pub const REST_DAYS: i64 = 2;
57
58/// Where a learner's streak stands on a given day
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum StreakStatus {
61    /// No learning days yet
62    NotStarted,
63    /// Learned today; `days` learning days in this run
64    LearnedToday { days: usize },
65    /// Resting: the run of `days` continues if they learn within `rest_days_left` days
66    Resting { days: usize, rest_days_left: i64 },
67    /// The last run ended after a long break; the next learning day starts a new one
68    WelcomeBack { best: usize },
69}
70
71/// One track's progress against a lesson library
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct TrackProgress {
74    pub track: Track,
75    /// Lessons in the track, in library order
76    pub total: usize,
77    /// Titles of finished lessons, in library order
78    pub completed: Vec<String>,
79    /// Titles of lessons still to do, in library order
80    pub remaining: Vec<String>,
81    /// Finished lessons that were also mastered
82    pub mastered: usize,
83}
84
85impl TrackProgress {
86    /// Every lesson in the track is finished (and the track has lessons)
87    pub fn is_complete(&self) -> bool {
88        self.total > 0 && self.remaining.is_empty()
89    }
90
91    /// Every lesson in the track was finished without needing the answer
92    pub fn is_mastered(&self) -> bool {
93        self.is_complete() && self.mastered == self.total
94    }
95}
96
97/// Today's date where the learner is (streaks follow the learner's own days)
98pub fn local_today() -> NaiveDate {
99    Local::now().date_naive()
100}
101
102impl UserStats {
103    /// Create a new user stats tracker
104    pub fn new() -> Self {
105        Self {
106            lessons_completed: HashSet::new(),
107            lessons_by_difficulty: HashMap::new(),
108            commands_used: HashSet::new(),
109            total_commands_executed: 0,
110            current_streak: 0,
111            longest_streak: 0,
112            last_active_date: local_today(),
113            total_time_seconds: 0,
114            session_start: Utc::now(),
115            achievements: UserAchievements::new(),
116            completed_challenges: HashSet::new(),
117            lessons_mastered: HashSet::new(),
118        }
119    }
120
121    /// Count today as a learning day. Call when the learner actually does
122    /// something (runs a command, passes a step), not merely on start-up.
123    pub fn update_streak(&mut self) {
124        self.record_learning_day(local_today());
125    }
126
127    /// Count `today` as a learning day. Up to `REST_DAYS` days off between
128    /// learning days keep the run going; a longer break starts a new run.
129    /// The best run is always kept.
130    pub fn record_learning_day(&mut self, today: NaiveDate) {
131        let gap = (today - self.last_active_date).num_days();
132        if self.current_streak == 0 {
133            self.current_streak = 1;
134        } else if gap <= 0 {
135            // Same day (or the clock moved back): nothing to add
136            return;
137        } else if gap <= REST_DAYS + 1 {
138            self.current_streak += 1;
139        } else {
140            self.current_streak = 1;
141        }
142        self.last_active_date = today;
143        self.longest_streak = self.longest_streak.max(self.current_streak);
144    }
145
146    /// End a run whose rest days have run out, without counting `today` as
147    /// a learning day. Call on start-up so screens never show a run that
148    /// has already ended.
149    pub fn settle_streak(&mut self, today: NaiveDate) {
150        if self.current_streak > 0 && (today - self.last_active_date).num_days() > REST_DAYS + 1 {
151            self.current_streak = 0;
152        }
153    }
154
155    /// Where the streak stands on `today`
156    pub fn streak_status(&self, today: NaiveDate) -> StreakStatus {
157        let gap = (today - self.last_active_date).num_days();
158        if self.current_streak == 0 {
159            if self.longest_streak > 0 {
160                StreakStatus::WelcomeBack { best: self.longest_streak }
161            } else {
162                StreakStatus::NotStarted
163            }
164        } else if gap <= 0 {
165            StreakStatus::LearnedToday { days: self.current_streak }
166        } else if gap <= REST_DAYS + 1 {
167            StreakStatus::Resting {
168                days: self.current_streak,
169                rest_days_left: REST_DAYS + 1 - gap,
170            }
171        } else {
172            StreakStatus::WelcomeBack { best: self.longest_streak }
173        }
174    }
175
176    /// Record that a lesson was finished without the answer being revealed
177    pub fn record_lesson_mastered(&mut self, lesson_id: String) {
178        self.lessons_mastered.insert(lesson_id);
179    }
180
181    /// Progress through one track of the given library
182    pub fn track_progress(&self, library: &LessonLibrary, track: Track) -> TrackProgress {
183        let mut progress = TrackProgress {
184            track,
185            total: 0,
186            completed: Vec::new(),
187            remaining: Vec::new(),
188            mastered: 0,
189        };
190        for lesson in library.all().into_iter().filter(|l| l.track == track) {
191            progress.total += 1;
192            if self.lessons_completed.contains(&lesson.id) {
193                progress.completed.push(lesson.title.clone());
194                if self.lessons_mastered.contains(&lesson.id) {
195                    progress.mastered += 1;
196                }
197            } else {
198                progress.remaining.push(lesson.title.clone());
199            }
200        }
201        progress
202    }
203
204    /// Record completion of a lesson
205    pub fn record_lesson_completion(&mut self, lesson_id: String, difficulty: Difficulty) {
206        if self.lessons_completed.insert(lesson_id) {
207            // Only increment if this is a new completion
208            *self.lessons_by_difficulty.entry(difficulty).or_insert(0) += 1;
209        }
210    }
211
212    /// Record use of a command
213    pub fn record_command_use(&mut self, command: String) {
214        self.commands_used.insert(command);
215        self.total_commands_executed += 1;
216    }
217
218    /// Mark a challenge as completed
219    pub fn complete_challenge(&mut self, challenge_id: String) {
220        self.completed_challenges.insert(challenge_id);
221    }
222
223    /// Record a one-shot feature-usage event from the UI layer and return any
224    /// newly unlocked achievements.
225    ///
226    /// The TUI should call this at the corresponding feature-usage call sites.
227    /// Recognized event keys (each backs a `CompleteChallenge` achievement
228    /// condition, see `achievement::all_achievements`):
229    ///
230    /// - `"use_ai"`           — the AI assistant was invoked/toggled
231    /// - `"change_theme"`     — the user changed the color theme
232    /// - `"visit_all_panels"` — the user has visited every panel at least once
233    /// - `"use_context"`      — the context detection feature was used
234    /// - `"speed_lesson"`     — a lesson was completed in under 5 minutes
235    /// - `"perfect_lesson"`   — a lesson was completed without failed attempts
236    /// - `"first_challenge"`  — the user completed their first daily/weekly
237    ///   challenge (sent automatically by `ChallengeManager::check_command`)
238    ///
239    /// Calling this repeatedly with the same key is safe (idempotent).
240    pub fn record_event(&mut self, event_key: &str) -> Vec<Achievement> {
241        self.complete_challenge(event_key.to_string());
242        self.check_achievements()
243    }
244
245    /// Check all achievements against the built-in lesson library and return
246    /// newly unlocked ones.
247    ///
248    /// If user lessons have been loaded via `LessonLibrary::load_from_dir`,
249    /// prefer `check_achievements_with_library` so per-difficulty totals are
250    /// accurate.
251    pub fn check_achievements(&mut self) -> Vec<Achievement> {
252        self.check_achievements_with_counts(builtin_difficulty_counts())
253    }
254
255    /// Check all achievements using real per-difficulty lesson counts from
256    /// the given library, and return newly unlocked ones.
257    pub fn check_achievements_with_library(&mut self, library: &LessonLibrary) -> Vec<Achievement> {
258        self.check_achievements_with_counts(&library.difficulty_counts())
259    }
260
261    fn check_achievements_with_counts(
262        &mut self,
263        difficulty_totals: &HashMap<Difficulty, usize>,
264    ) -> Vec<Achievement> {
265        let mut newly_unlocked = Vec::new();
266
267        for achievement in all_achievements() {
268            // Skip if already unlocked
269            if self.achievements.is_unlocked(&achievement.id) {
270                continue;
271            }
272
273            // Check if condition is met
274            if self.check_unlock_condition(&achievement.condition, difficulty_totals) {
275                self.achievements.unlock(achievement.id.clone());
276                newly_unlocked.push(achievement);
277            }
278        }
279
280        newly_unlocked
281    }
282
283    /// Check if a specific unlock condition is met
284    fn check_unlock_condition(
285        &self,
286        condition: &UnlockCondition,
287        difficulty_totals: &HashMap<Difficulty, usize>,
288    ) -> bool {
289        match condition {
290            UnlockCondition::CompleteLesson(lesson_id) => {
291                self.lessons_completed.contains(lesson_id)
292            }
293            UnlockCondition::CompleteLessons(count) => {
294                self.lessons_completed.len() >= *count
295            }
296            UnlockCondition::CompleteAllDifficulty(difficulty) => {
297                let completed = self.lessons_by_difficulty.get(difficulty).unwrap_or(&0);
298                // Only unlockable when lessons of this difficulty actually
299                // exist; a difficulty with zero lessons never auto-unlocks.
300                match difficulty_totals.get(difficulty) {
301                    Some(&total) if total > 0 => *completed >= total,
302                    _ => false,
303                }
304            }
305            UnlockCondition::UseCommands(count) => {
306                self.commands_used.len() >= *count
307            }
308            UnlockCondition::MaintainStreak(days) => {
309                self.current_streak >= *days
310            }
311            UnlockCondition::CompleteChallenge(challenge_id) => {
312                self.completed_challenges.contains(challenge_id)
313            }
314            UnlockCondition::TimeOfDay { start, end } => {
315                let current_hour = Local::now().hour() as u8;
316                if start < end {
317                    current_hour >= *start && current_hour < *end
318                } else {
319                    // Handles wrap-around (e.g., 22-2 for late night)
320                    current_hour >= *start || current_hour < *end
321                }
322            }
323            UnlockCondition::IsWeekend => {
324                let weekday = Local::now().weekday();
325                weekday == Weekday::Sat || weekday == Weekday::Sun
326            }
327        }
328    }
329
330    /// Calculate completion percentage for a difficulty level, based on the
331    /// built-in lesson library. Use `completion_percentage_with_library` when
332    /// user lessons have been loaded.
333    pub fn completion_percentage(&self, difficulty: Difficulty) -> f32 {
334        self.completion_percentage_with_counts(difficulty, builtin_difficulty_counts())
335    }
336
337    /// Calculate completion percentage for a difficulty level using real
338    /// lesson counts from the given library.
339    pub fn completion_percentage_with_library(
340        &self,
341        difficulty: Difficulty,
342        library: &LessonLibrary,
343    ) -> f32 {
344        self.completion_percentage_with_counts(difficulty, &library.difficulty_counts())
345    }
346
347    fn completion_percentage_with_counts(
348        &self,
349        difficulty: Difficulty,
350        difficulty_totals: &HashMap<Difficulty, usize>,
351    ) -> f32 {
352        let completed = self.lessons_by_difficulty.get(&difficulty).unwrap_or(&0);
353        let total = *difficulty_totals.get(&difficulty).unwrap_or(&0);
354        if total == 0 {
355            return 0.0;
356        }
357        (*completed as f32 / total as f32) * 100.0
358    }
359
360    /// Get overall completion percentage across all lessons
361    pub fn overall_completion_percentage(&self, total_lessons: usize) -> f32 {
362        if total_lessons == 0 {
363            return 0.0;
364        }
365        (self.lessons_completed.len() as f32 / total_lessons as f32) * 100.0
366    }
367
368    /// Update total time spent so "Time Invested" accumulates.
369    ///
370    /// This is the TUI heartbeat API: call it periodically (e.g. once per
371    /// render tick or once a minute) AND on session end / before persisting
372    /// stats. Each call folds the elapsed time since `session_start` into
373    /// `total_time_seconds` and resets `session_start` to now, so calling it
374    /// at any frequency never double-counts time.
375    pub fn update_session_time(&mut self) {
376        let now = Utc::now();
377        let session_duration = (now - self.session_start).num_seconds();
378        if session_duration > 0 {
379            self.total_time_seconds += session_duration as u64;
380        }
381        self.session_start = now;
382    }
383
384    /// Get formatted time spent string (e.g., "2h 30m")
385    pub fn formatted_time_spent(&self) -> String {
386        let hours = self.total_time_seconds / 3600;
387        let minutes = (self.total_time_seconds % 3600) / 60;
388
389        if hours > 0 {
390            format!("{}h {}m", hours, minutes)
391        } else {
392            format!("{}m", minutes)
393        }
394    }
395
396    /// Get progress summary
397    pub fn progress_summary(&self) -> ProgressSummary {
398        ProgressSummary {
399            total_lessons_completed: self.lessons_completed.len(),
400            unique_commands_used: self.commands_used.len(),
401            total_commands_executed: self.total_commands_executed,
402            current_streak: self.current_streak,
403            longest_streak: self.longest_streak,
404            achievements_unlocked: self.achievements.total_unlocked(),
405            total_points: self.achievements.total_points(),
406            time_spent: self.formatted_time_spent(),
407        }
408    }
409
410    /// Get achievements that are close to being unlocked (for motivation)
411    pub fn nearly_unlocked_achievements(&self) -> Vec<(Achievement, f32)> {
412        let mut nearly_unlocked = Vec::new();
413
414        for achievement in all_achievements() {
415            if self.achievements.is_unlocked(&achievement.id) {
416                continue;
417            }
418
419            let progress = self.achievement_progress(&achievement.condition);
420            if progress >= 0.5 && progress < 1.0 {
421                nearly_unlocked.push((achievement, progress));
422            }
423        }
424
425        // Sort by progress (closest to completion first)
426        nearly_unlocked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
427        nearly_unlocked
428    }
429
430    /// Calculate progress towards an achievement (0.0 to 1.0)
431    fn achievement_progress(&self, condition: &UnlockCondition) -> f32 {
432        match condition {
433            UnlockCondition::CompleteLessons(target) => {
434                (self.lessons_completed.len() as f32 / *target as f32).min(1.0)
435            }
436            UnlockCondition::UseCommands(target) => {
437                (self.commands_used.len() as f32 / *target as f32).min(1.0)
438            }
439            UnlockCondition::MaintainStreak(target) => {
440                (self.current_streak as f32 / *target as f32).min(1.0)
441            }
442            UnlockCondition::CompleteLesson(_) |
443            UnlockCondition::CompleteAllDifficulty(_) |
444            UnlockCondition::CompleteChallenge(_) |
445            UnlockCondition::TimeOfDay { .. } |
446            UnlockCondition::IsWeekend => {
447                if self.check_unlock_condition(condition, builtin_difficulty_counts()) {
448                    1.0
449                } else {
450                    0.0
451                }
452            }
453        }
454    }
455}
456
457impl Default for UserStats {
458    fn default() -> Self {
459        Self::new()
460    }
461}
462
463/// Summary of user progress
464#[derive(Debug, Clone)]
465pub struct ProgressSummary {
466    pub total_lessons_completed: usize,
467    pub unique_commands_used: usize,
468    pub total_commands_executed: usize,
469    pub current_streak: usize,
470    pub longest_streak: usize,
471    pub achievements_unlocked: usize,
472    pub total_points: u32,
473    pub time_spent: String,
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn test_new_stats() {
482        let stats = UserStats::new();
483        assert_eq!(stats.lessons_completed.len(), 0);
484        assert_eq!(stats.current_streak, 0);
485        assert_eq!(stats.total_commands_executed, 0);
486    }
487
488    #[test]
489    fn test_record_lesson() {
490        let mut stats = UserStats::new();
491        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
492
493        assert_eq!(stats.lessons_completed.len(), 1);
494        assert_eq!(*stats.lessons_by_difficulty.get(&Difficulty::Beginner).unwrap(), 1);
495
496        // Recording same lesson again shouldn't increase count
497        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
498        assert_eq!(stats.lessons_completed.len(), 1);
499    }
500
501    #[test]
502    fn test_record_command() {
503        let mut stats = UserStats::new();
504        stats.record_command_use("ls".to_string());
505        stats.record_command_use("cd".to_string());
506        stats.record_command_use("ls".to_string()); // Duplicate
507
508        assert_eq!(stats.commands_used.len(), 2);
509        assert_eq!(stats.total_commands_executed, 3);
510    }
511
512    #[test]
513    fn test_check_achievements() {
514        let mut stats = UserStats::new();
515
516        // Complete first lesson
517        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
518
519        let unlocked = stats.check_achievements();
520        assert!(!unlocked.is_empty());
521        assert!(unlocked.iter().any(|a| a.id == "first_steps"));
522    }
523
524    #[test]
525    fn test_completion_percentage() {
526        let mut stats = UserStats::new();
527        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
528        stats.record_lesson_completion("lesson2".to_string(), Difficulty::Beginner);
529
530        let total_beginner = LessonLibrary::new()
531            .by_difficulty(Difficulty::Beginner)
532            .len();
533        let expected = (2.0 / total_beginner as f32) * 100.0;
534        let percentage = stats.completion_percentage(Difficulty::Beginner);
535        assert_eq!(percentage, expected);
536
537        // A difficulty with no available lessons reports 0%
538        assert_eq!(stats.completion_percentage(Difficulty::Expert), 0.0);
539    }
540
541    #[test]
542    fn test_complete_all_difficulty_uses_real_lesson_counts() {
543        let library = LessonLibrary::new();
544        let beginner_lessons: Vec<String> = library
545            .by_difficulty(Difficulty::Beginner)
546            .iter()
547            .map(|l| l.id.clone())
548            .collect();
549        assert!(!beginner_lessons.is_empty());
550
551        let mut stats = UserStats::new();
552
553        // Complete all but one beginner lesson: not yet unlocked
554        for id in &beginner_lessons[..beginner_lessons.len() - 1] {
555            stats.record_lesson_completion(id.clone(), Difficulty::Beginner);
556        }
557        stats.check_achievements_with_library(&library);
558        assert!(!stats.achievements.is_unlocked("beginner_complete"));
559
560        // Complete the last one: unlocked
561        stats.record_lesson_completion(
562            beginner_lessons.last().unwrap().clone(),
563            Difficulty::Beginner,
564        );
565        stats.check_achievements_with_library(&library);
566        assert!(stats.achievements.is_unlocked("beginner_complete"));
567
568        // Difficulties with zero available lessons never auto-unlock
569        assert!(!stats.achievements.is_unlocked("advanced_complete"));
570        assert!(!stats.achievements.is_unlocked("expert_complete"));
571    }
572
573    #[test]
574    fn test_navigation_master_matches_real_lesson_id() {
575        let mut stats = UserStats::new();
576        stats.record_lesson_completion("nav-basics".to_string(), Difficulty::Beginner);
577        stats.check_achievements();
578        assert!(stats.achievements.is_unlocked("navigation_master"));
579    }
580
581    #[test]
582    fn test_complete_lesson_achievement_ids_exist_in_library() {
583        // Guard against typos: every CompleteLesson condition must reference
584        // a real lesson id in the built-in library.
585        let library = LessonLibrary::new();
586        for achievement in all_achievements() {
587            if let UnlockCondition::CompleteLesson(lesson_id) = &achievement.condition {
588                assert!(
589                    library.get(lesson_id).is_some(),
590                    "Achievement '{}' references unknown lesson id '{}'",
591                    achievement.id,
592                    lesson_id
593                );
594            }
595        }
596    }
597
598    #[test]
599    fn test_record_event_unlocks_exploration_achievements() {
600        let mut stats = UserStats::new();
601
602        let unlocked = stats.record_event("use_ai");
603        assert!(unlocked.iter().any(|a| a.id == "ai_assistant"));
604
605        stats.record_event("change_theme");
606        assert!(stats.achievements.is_unlocked("theme_explorer"));
607
608        stats.record_event("visit_all_panels");
609        assert!(stats.achievements.is_unlocked("panel_master"));
610
611        stats.record_event("use_context");
612        assert!(stats.achievements.is_unlocked("context_aware"));
613
614        stats.record_event("speed_lesson");
615        assert!(stats.achievements.is_unlocked("speed_learner"));
616
617        stats.record_event("perfect_lesson");
618        assert!(stats.achievements.is_unlocked("perfect_lesson"));
619
620        stats.record_event("first_challenge");
621        assert!(stats.achievements.is_unlocked("challenge_accepted"));
622
623        // Idempotent: repeating an event doesn't re-unlock
624        let again = stats.record_event("use_ai");
625        assert!(again.is_empty());
626    }
627
628    #[test]
629    fn test_time_tracking() {
630        let mut stats = UserStats::new();
631        stats.total_time_seconds = 7200 + 1800; // 2h 30m
632
633        let formatted = stats.formatted_time_spent();
634        assert_eq!(formatted, "2h 30m");
635    }
636
637    #[test]
638    fn test_progress_summary() {
639        let mut stats = UserStats::new();
640        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
641        stats.record_command_use("ls".to_string());
642
643        let summary = stats.progress_summary();
644        assert_eq!(summary.total_lessons_completed, 1);
645        assert_eq!(summary.unique_commands_used, 1);
646    }
647
648    fn day(n: u32) -> NaiveDate {
649        NaiveDate::from_ymd_opt(2026, 10, n).unwrap()
650    }
651
652    #[test]
653    fn first_learning_day_counts() {
654        let mut stats = UserStats::new();
655        stats.last_active_date = day(1);
656        stats.record_learning_day(day(1));
657        assert_eq!(stats.current_streak, 1);
658        assert_eq!(stats.streak_status(day(1)), StreakStatus::LearnedToday { days: 1 });
659    }
660
661    #[test]
662    fn rest_days_keep_the_streak() {
663        let mut stats = UserStats::new();
664        stats.last_active_date = day(1);
665        stats.record_learning_day(day(1));
666        stats.record_learning_day(day(1)); // same day twice
667        stats.record_learning_day(day(2));
668        assert_eq!(stats.current_streak, 2);
669
670        // Two days off, then back: the run continues
671        assert_eq!(
672            stats.streak_status(day(4)),
673            StreakStatus::Resting { days: 2, rest_days_left: 1 }
674        );
675        // Day 5 is the last day that keeps it going
676        assert_eq!(
677            stats.streak_status(day(5)),
678            StreakStatus::Resting { days: 2, rest_days_left: 0 }
679        );
680        stats.settle_streak(day(5));
681        assert_eq!(stats.current_streak, 2);
682        stats.record_learning_day(day(5));
683        assert_eq!(stats.current_streak, 3);
684        assert_eq!(stats.longest_streak, 3);
685    }
686
687    #[test]
688    fn a_long_break_starts_a_new_run_and_keeps_the_best() {
689        let mut stats = UserStats::new();
690        stats.last_active_date = day(1);
691        for d in 1..=4 {
692            stats.record_learning_day(day(d));
693        }
694        assert_eq!(stats.current_streak, 4);
695
696        // Three days off is past the rest allowance
697        stats.settle_streak(day(8));
698        assert_eq!(stats.current_streak, 0);
699        assert_eq!(stats.streak_status(day(8)), StreakStatus::WelcomeBack { best: 4 });
700        stats.record_learning_day(day(8));
701        assert_eq!(stats.current_streak, 1);
702        assert_eq!(stats.longest_streak, 4);
703    }
704
705    #[test]
706    fn clock_moving_back_changes_nothing() {
707        let mut stats = UserStats::new();
708        stats.last_active_date = day(5);
709        stats.record_learning_day(day(5));
710        stats.record_learning_day(day(6));
711        stats.record_learning_day(day(3));
712        assert_eq!(stats.current_streak, 2);
713        assert_eq!(stats.last_active_date, day(6));
714    }
715
716    #[test]
717    fn track_progress_counts_completion_and_mastery() {
718        let library = LessonLibrary::new();
719        let explorer: Vec<_> = library
720            .all()
721            .into_iter()
722            .filter(|l| l.track == Track::Explorer)
723            .map(|l| l.id.clone())
724            .collect();
725        assert!(explorer.len() > 1);
726
727        let mut stats = UserStats::new();
728        let fresh = stats.track_progress(&library, Track::Explorer);
729        assert_eq!(fresh.total, explorer.len());
730        assert!(fresh.completed.is_empty());
731        assert!(!fresh.is_complete());
732
733        for id in &explorer {
734            stats.lessons_completed.insert(id.clone());
735        }
736        stats.record_lesson_mastered(explorer[0].clone());
737        let done = stats.track_progress(&library, Track::Explorer);
738        assert!(done.is_complete());
739        assert!(!done.is_mastered());
740        assert_eq!(done.mastered, 1);
741
742        for id in &explorer {
743            stats.record_lesson_mastered(id.clone());
744        }
745        assert!(stats.track_progress(&library, Track::Explorer).is_mastered());
746        // The other track is untouched
747        assert!(!stats.track_progress(&library, Track::Operator).is_complete());
748    }
749
750    #[test]
751    fn old_save_files_without_mastery_still_load() {
752        let mut value = serde_json::to_value(UserStats::new()).unwrap();
753        value.as_object_mut().unwrap().remove("lessons_mastered");
754        let stats: UserStats = serde_json::from_value(value).unwrap();
755        assert!(stats.lessons_mastered.is_empty());
756    }
757
758    #[test]
759    fn test_challenge_completion() {
760        let mut stats = UserStats::new();
761        stats.complete_challenge("use_ai".to_string());
762
763        assert!(stats.completed_challenges.contains("use_ai"));
764    }
765}