use std::collections::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use crate::engine::key_stats::KeyStatsStore; use crate::keyboard::display::{BACKSPACE, SPACE}; use crate::l10n::language_pack::{ DEFAULT_LATIN_PRIMARY_SEQUENCE, normalized_primary_letter_sequence, }; /// Events returned by `SkillTree::update` describing what changed. pub struct SkillTreeUpdate { pub newly_unlocked: Vec, pub newly_mastered: Vec, pub branches_newly_available: Vec, pub branches_newly_completed: Vec, pub all_keys_unlocked: bool, pub all_keys_mastered: bool, } // --- Branch ID --- #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum BranchId { Lowercase, Capitals, Numbers, ProsePunctuation, Whitespace, CodeSymbols, } impl BranchId { pub fn to_key(self) -> &'static str { match self { BranchId::Lowercase => "lowercase", BranchId::Capitals => "capitals", BranchId::Numbers => "numbers", BranchId::ProsePunctuation => "prose_punctuation", BranchId::Whitespace => "whitespace", BranchId::CodeSymbols => "code_symbols", } } #[allow(dead_code)] pub fn from_key(key: &str) -> Option { match key { "lowercase" => Some(BranchId::Lowercase), "capitals" => Some(BranchId::Capitals), "numbers" => Some(BranchId::Numbers), "prose_punctuation" => Some(BranchId::ProsePunctuation), "whitespace" => Some(BranchId::Whitespace), "code_symbols" => Some(BranchId::CodeSymbols), _ => None, } } pub fn all() -> &'static [BranchId] { &[ BranchId::Lowercase, BranchId::Capitals, BranchId::Numbers, BranchId::ProsePunctuation, BranchId::Whitespace, BranchId::CodeSymbols, ] } } // --- Branch Status --- #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum BranchStatus { Locked, Available, InProgress, Complete, } // --- Static Definitions --- pub struct LevelDefinition { pub name_key: &'static str, pub keys: &'static [char], } impl LevelDefinition { pub fn display_name(&self) -> String { crate::i18n::t!(self.name_key).to_string() } } pub struct BranchDefinition { pub id: BranchId, pub name_key: &'static str, pub levels: &'static [LevelDefinition], } impl BranchDefinition { pub fn display_name(&self) -> String { crate::i18n::t!(self.name_key).to_string() } } // Lowercase metadata remains for static branch lookup/UI labels. Runtime // progression and unlock counts are driven by `SkillTree::primary_letters`. const LOWERCASE_LEVELS: &[LevelDefinition] = &[LevelDefinition { name_key: "skill_tree.level_frequency_order", keys: &[ 'e', 't', 'a', 'o', 'i', 'n', 's', 'h', 'r', 'd', 'l', 'c', 'u', 'm', 'w', 'f', 'g', 'y', 'p', 'b', 'v', 'k', 'j', 'x', 'q', 'z', ], }]; const CAPITALS_LEVELS: &[LevelDefinition] = &[ LevelDefinition { name_key: "skill_tree.level_common_sentence_capitals", keys: &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'], }, LevelDefinition { name_key: "skill_tree.level_name_capitals", keys: &['J', 'D', 'R', 'C', 'E', 'N', 'P', 'L', 'F', 'G'], }, LevelDefinition { name_key: "skill_tree.level_remaining_capitals", keys: &['O', 'U', 'K', 'V', 'Y', 'X', 'Q', 'Z'], }, ]; const NUMBERS_LEVELS: &[LevelDefinition] = &[ LevelDefinition { name_key: "skill_tree.level_common_digits", keys: &['1', '2', '3', '4', '5'], }, LevelDefinition { name_key: "skill_tree.level_all_digits", keys: &['0', '6', '7', '8', '9'], }, ]; const PROSE_PUNCTUATION_LEVELS: &[LevelDefinition] = &[ LevelDefinition { name_key: "skill_tree.level_essential", keys: &['.', ',', '\''], }, LevelDefinition { name_key: "skill_tree.level_common", keys: &[';', ':', '"', '-'], }, LevelDefinition { name_key: "skill_tree.level_expressive", keys: &['?', '!', '(', ')'], }, ]; const WHITESPACE_LEVELS: &[LevelDefinition] = &[ LevelDefinition { name_key: "skill_tree.level_enter_return", keys: &['\n'], }, LevelDefinition { name_key: "skill_tree.level_tab_indent", keys: &['\t'], }, ]; const CODE_SYMBOLS_LEVELS: &[LevelDefinition] = &[ LevelDefinition { name_key: "skill_tree.level_arithmetic_assignment", keys: &['=', '+', '*', '/', '-'], }, LevelDefinition { name_key: "skill_tree.level_grouping", keys: &['{', '}', '[', ']', '<', '>'], }, LevelDefinition { name_key: "skill_tree.level_logic_reference", keys: &['&', '|', '^', '~', '!'], }, LevelDefinition { name_key: "skill_tree.level_special", keys: &['@', '#', '$', '%', '_', '\\', '`'], }, ]; pub const ALL_BRANCHES: &[BranchDefinition] = &[ BranchDefinition { id: BranchId::Lowercase, name_key: "skill_tree.branch_primary_letters", levels: LOWERCASE_LEVELS, }, BranchDefinition { id: BranchId::Capitals, name_key: "skill_tree.branch_capital_letters", levels: CAPITALS_LEVELS, }, BranchDefinition { id: BranchId::Numbers, name_key: "skill_tree.branch_numbers", levels: NUMBERS_LEVELS, }, BranchDefinition { id: BranchId::ProsePunctuation, name_key: "skill_tree.branch_prose_punctuation", levels: PROSE_PUNCTUATION_LEVELS, }, BranchDefinition { id: BranchId::Whitespace, name_key: "skill_tree.branch_whitespace", levels: WHITESPACE_LEVELS, }, BranchDefinition { id: BranchId::CodeSymbols, name_key: "skill_tree.branch_code_symbols", levels: CODE_SYMBOLS_LEVELS, }, ]; /// Find which branch and level a key belongs to. /// Returns (branch_def, level_def, 1-based position in level). pub fn find_key_branch(ch: char) -> Option<(&'static BranchDefinition, &'static LevelDefinition, usize)> { for branch in ALL_BRANCHES { for level in branch.levels { if let Some(pos) = level.keys.iter().position(|&k| k == ch) { return Some((branch, level, pos + 1)); } } } None } pub fn get_branch_definition(id: BranchId) -> &'static BranchDefinition { ALL_BRANCHES .iter() .find(|b| b.id == id) .expect("branch definition not found") } // --- Persisted Progress --- #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BranchProgress { pub status: BranchStatus, pub current_level: usize, } impl Default for BranchProgress { fn default() -> Self { Self { status: BranchStatus::Locked, current_level: 0, } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SkillTreeProgress { pub branches: HashMap, } impl Default for SkillTreeProgress { fn default() -> Self { let mut branches = HashMap::new(); // Lowercase starts as InProgress; everything else Locked branches.insert( BranchId::Lowercase.to_key().to_string(), BranchProgress { status: BranchStatus::InProgress, current_level: 0, }, ); for &id in &[ BranchId::Capitals, BranchId::Numbers, BranchId::ProsePunctuation, BranchId::Whitespace, BranchId::CodeSymbols, ] { branches.insert(id.to_key().to_string(), BranchProgress::default()); } Self { branches } } } // --- Skill Tree Engine --- /// The scope for key collection and focus selection. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DrillScope { /// Global adaptive: all InProgress + Complete branches Global, /// Branch-specific drill: specific branch + primary-letter background Branch(BranchId), } pub struct SkillTree { pub progress: SkillTreeProgress, pub total_unique_keys: usize, primary_letters: Vec, } /// Number of lowercase letters to start with before unlocking one-at-a-time const LOWERCASE_MIN_KEYS: usize = 6; const ALWAYS_UNLOCKED_KEYS: &[char] = &[SPACE, BACKSPACE]; impl SkillTree { pub fn new(progress: SkillTreeProgress) -> Self { Self::new_with_primary_sequence(progress, DEFAULT_LATIN_PRIMARY_SEQUENCE) } pub fn new_with_primary_sequence(progress: SkillTreeProgress, sequence: &str) -> Self { let primary_letters = Self::normalize_primary_sequence(sequence); let total_unique_keys = Self::compute_total_unique_keys(&primary_letters); Self { progress, total_unique_keys, primary_letters, } } fn normalize_primary_sequence(sequence: &str) -> Vec { let normalized = normalized_primary_letter_sequence(sequence); if normalized.is_empty() { DEFAULT_LATIN_PRIMARY_SEQUENCE.chars().collect() } else { normalized } } fn compute_total_unique_keys(primary_letters: &[char]) -> usize { let mut all_keys: HashSet = HashSet::new(); for branch in ALL_BRANCHES { if branch.id == BranchId::Lowercase { continue; } for level in branch.levels { for &key in level.keys { all_keys.insert(key); } } } all_keys.extend(primary_letters.iter().copied()); all_keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied()); all_keys.len() } pub fn primary_letters(&self) -> &[char] { &self.primary_letters } pub fn branch_status(&self, id: BranchId) -> &BranchStatus { self.progress .branches .get(id.to_key()) .map(|bp| &bp.status) .unwrap_or(&BranchStatus::Locked) } pub fn branch_progress(&self, id: BranchId) -> &BranchProgress { static DEFAULT: BranchProgress = BranchProgress { status: BranchStatus::Locked, current_level: 0, }; self.progress.branches.get(id.to_key()).unwrap_or(&DEFAULT) } pub fn branch_progress_mut(&mut self, id: BranchId) -> &mut BranchProgress { self.progress .branches .entry(id.to_key().to_string()) .or_default() } /// Start a branch (transition Available -> InProgress). pub fn start_branch(&mut self, id: BranchId) { let bp = self.branch_progress_mut(id); if bp.status == BranchStatus::Available { bp.status = BranchStatus::InProgress; bp.current_level = 0; } } /// Collect all unlocked keys for the given scope. pub fn unlocked_keys(&self, scope: DrillScope) -> Vec { match scope { DrillScope::Global => self.global_unlocked_keys(), DrillScope::Branch(id) => self.branch_unlocked_keys(id), } } fn global_unlocked_keys(&self) -> Vec { let mut keys = ALWAYS_UNLOCKED_KEYS.to_vec(); for branch_def in ALL_BRANCHES { let bp = self.branch_progress(branch_def.id); match bp.status { BranchStatus::InProgress => { // For lowercase, use the progressive unlock system if branch_def.id == BranchId::Lowercase { keys.extend(self.lowercase_unlocked_keys()); } else { // Include current level's keys + all prior levels for (i, level) in branch_def.levels.iter().enumerate() { if i <= bp.current_level { keys.extend_from_slice(level.keys); } } } } BranchStatus::Complete => { if branch_def.id == BranchId::Lowercase { keys.extend(self.primary_letters.iter().copied()); } else { for level in branch_def.levels { keys.extend_from_slice(level.keys); } } } _ => {} } } keys } fn branch_unlocked_keys(&self, id: BranchId) -> Vec { let mut keys = ALWAYS_UNLOCKED_KEYS.to_vec(); // Always include primary-letter background keys if id != BranchId::Lowercase { let lowercase_bp = self.branch_progress(BranchId::Lowercase); match lowercase_bp.status { BranchStatus::InProgress => keys.extend(self.lowercase_unlocked_keys()), BranchStatus::Complete => { keys.extend(self.primary_letters.iter().copied()); } _ => {} } } // Include keys from the target branch let branch_def = get_branch_definition(id); let bp = self.branch_progress(id); if id == BranchId::Lowercase { keys.extend(self.lowercase_unlocked_keys()); } else { match bp.status { BranchStatus::InProgress => { for (i, level) in branch_def.levels.iter().enumerate() { if i <= bp.current_level { keys.extend_from_slice(level.keys); } } } BranchStatus::Complete => { for level in branch_def.levels { keys.extend_from_slice(level.keys); } } _ => {} } } keys } /// Get the progressively-unlocked lowercase keys (mirrors old LetterUnlock logic). fn lowercase_unlocked_keys(&self) -> Vec { let bp = self.branch_progress(BranchId::Lowercase); let all_keys = self.primary_letters(); match bp.status { BranchStatus::Complete => all_keys.to_vec(), BranchStatus::InProgress => { // current_level represents number of keys unlocked beyond LOWERCASE_MIN_KEYS let count = (LOWERCASE_MIN_KEYS + bp.current_level).min(all_keys.len()); all_keys[..count].to_vec() } _ => Vec::new(), } } /// Number of unlocked lowercase letters (for display). pub fn lowercase_unlocked_count(&self) -> usize { self.lowercase_unlocked_keys().len() } /// Find the focused (weakest) key for the given scope. pub fn focused_key(&self, scope: DrillScope, stats: &KeyStatsStore) -> Option { match scope { DrillScope::Global => self.global_focused_key(stats), DrillScope::Branch(id) => self.branch_focused_key(id, stats), } } fn global_focused_key(&self, stats: &KeyStatsStore) -> Option { // Collect keys from all InProgress branches (current level only) + complete branches let mut focus_candidates = Vec::new(); for branch_def in ALL_BRANCHES { let bp = self.branch_progress(branch_def.id); match bp.status { BranchStatus::InProgress => { if branch_def.id == BranchId::Lowercase { focus_candidates.extend(self.lowercase_unlocked_keys()); } else if bp.current_level < branch_def.levels.len() { // Only current level keys are focus candidates focus_candidates .extend_from_slice(branch_def.levels[bp.current_level].keys); // Plus prior level keys for reinforcement for i in 0..bp.current_level { focus_candidates.extend_from_slice(branch_def.levels[i].keys); } } } BranchStatus::Complete => { if branch_def.id == BranchId::Lowercase { focus_candidates.extend(self.primary_letters.iter().copied()); } else { for level in branch_def.levels { focus_candidates.extend_from_slice(level.keys); } } } _ => {} } } Self::weakest_key(&focus_candidates, stats) } fn branch_focused_key(&self, id: BranchId, stats: &KeyStatsStore) -> Option { let branch_def = get_branch_definition(id); let bp = self.branch_progress(id); if id == BranchId::Lowercase { return Self::weakest_key(&self.lowercase_unlocked_keys(), stats); } match bp.status { BranchStatus::InProgress if bp.current_level < branch_def.levels.len() => { // Focus only within current level's keys let current_keys = branch_def.levels[bp.current_level].keys; Self::weakest_key(¤t_keys.to_vec(), stats) } _ => None, } } fn weakest_key(keys: &[char], stats: &KeyStatsStore) -> Option { keys.iter() .filter(|&&ch| stats.get_confidence(ch) < 1.0) .min_by(|&&a, &&b| { stats .get_confidence(a) .partial_cmp(&stats.get_confidence(b)) .unwrap_or(std::cmp::Ordering::Equal) }) .copied() } /// Update skill tree progress based on current key stats. /// Call after updating KeyStatsStore. /// /// `before_stats` is an optional snapshot of key stats *before* this drill's data was added. /// When provided, it's used to detect which keys were newly mastered (confidence crossing 1.0). /// Returns a `SkillTreeUpdate` describing which keys were newly unlocked or mastered. pub fn update( &mut self, stats: &KeyStatsStore, before_stats: Option<&KeyStatsStore>, ) -> SkillTreeUpdate { // Snapshot unlocked keys before tree structure changes let before_unlocked: HashSet = self.unlocked_keys(DrillScope::Global).into_iter().collect(); // Snapshot branch statuses before any updates let before_branch_statuses: HashMap = BranchId::all() .iter() .map(|&id| (id, self.branch_status(id).clone())) .collect(); let before_unlocked_count = self.total_unlocked_count(); // Update lowercase branch (progressive unlock) self.update_lowercase(stats); // Check if lowercase is complete -> unlock other branches // Snapshot non-lowercase branch statuses before auto-unlock (canonical order) const NON_LOWERCASE_BRANCHES: &[BranchId] = &[ BranchId::Capitals, BranchId::Numbers, BranchId::ProsePunctuation, BranchId::Whitespace, BranchId::CodeSymbols, ]; let before_auto_unlock: Vec<(BranchId, BranchStatus)> = NON_LOWERCASE_BRANCHES .iter() .map(|&id| (id, self.branch_status(id).clone())) .collect(); if *self.branch_status(BranchId::Lowercase) == BranchStatus::Complete { for &id in NON_LOWERCASE_BRANCHES { let bp = self.branch_progress_mut(id); if bp.status == BranchStatus::Locked { bp.status = BranchStatus::Available; } } } // Detect Locked → Available transitions (maintains canonical order) let branches_newly_available: Vec = before_auto_unlock .iter() .filter(|(id, before_status)| { *before_status == BranchStatus::Locked && *self.branch_status(*id) == BranchStatus::Available }) .map(|(id, _)| *id) .collect(); // Update InProgress branches (non-lowercase) for branch_def in ALL_BRANCHES { if branch_def.id == BranchId::Lowercase { continue; } let bp = self.branch_progress(branch_def.id).clone(); if bp.status != BranchStatus::InProgress { continue; } self.update_branch_level(branch_def, stats); } // Detect branches that became Complete let branches_newly_completed: Vec = BranchId::all() .iter() .filter(|&&id| { before_branch_statuses .get(&id) .map(|s| *s != BranchStatus::Complete) .unwrap_or(true) && *self.branch_status(id) == BranchStatus::Complete }) .copied() .collect(); // Detect all keys unlocked let after_unlocked_count = self.total_unlocked_count(); let all_keys_unlocked = after_unlocked_count == self.total_unique_keys && before_unlocked_count != self.total_unique_keys; // Detect all keys mastered let all_complete_now = BranchId::all() .iter() .all(|&id| *self.branch_status(id) == BranchStatus::Complete); let all_complete_before = BranchId::all() .iter() .all(|id| before_branch_statuses.get(id) == Some(&BranchStatus::Complete)); let all_keys_mastered = all_complete_now && !all_complete_before; // Snapshot after let after_unlocked: HashSet = self.unlocked_keys(DrillScope::Global).into_iter().collect(); let newly_unlocked: Vec = after_unlocked .difference(&before_unlocked) .copied() .collect(); // Detect mastery: keys that were unlocked before, had confidence < 1.0 in before_stats, // but now have confidence >= 1.0 in current stats let newly_mastered: Vec = if let Some(before) = before_stats { before_unlocked .iter() .filter(|&&ch| before.get_confidence(ch) < 1.0 && stats.get_confidence(ch) >= 1.0) .copied() .collect() } else { Vec::new() }; SkillTreeUpdate { newly_unlocked, newly_mastered, branches_newly_available, branches_newly_completed, all_keys_unlocked, all_keys_mastered, } } fn update_lowercase(&mut self, stats: &KeyStatsStore) { let bp = self.branch_progress(BranchId::Lowercase).clone(); if bp.status != BranchStatus::InProgress { return; } let all_keys = self.primary_letters.clone(); let current_count = LOWERCASE_MIN_KEYS + bp.current_level; if current_count >= all_keys.len() { // All primary letters unlocked, check if all confident let all_confident = all_keys.iter().all(|&ch| stats.get_confidence(ch) >= 1.0); if all_confident { let bp_mut = self.branch_progress_mut(BranchId::Lowercase); bp_mut.status = BranchStatus::Complete; bp_mut.current_level = all_keys.len() - LOWERCASE_MIN_KEYS; } return; } // Check if all current keys are confident -> unlock next let current_keys = &all_keys[..current_count]; let all_confident = current_keys .iter() .all(|&ch| stats.get_confidence(ch) >= 1.0); if all_confident { let bp_mut = self.branch_progress_mut(BranchId::Lowercase); bp_mut.current_level += 1; } } fn update_branch_level(&mut self, branch_def: &BranchDefinition, stats: &KeyStatsStore) { let bp = self.branch_progress(branch_def.id).clone(); if bp.current_level >= branch_def.levels.len() { // Already past last level, mark complete let bp_mut = self.branch_progress_mut(branch_def.id); bp_mut.status = BranchStatus::Complete; return; } // Check if all keys in current level are confident let current_level_keys = branch_def.levels[bp.current_level].keys; let all_confident = current_level_keys .iter() .all(|&ch| stats.get_confidence(ch) >= 1.0); if all_confident { let bp_mut = self.branch_progress_mut(branch_def.id); bp_mut.current_level += 1; if bp_mut.current_level >= branch_def.levels.len() { bp_mut.status = BranchStatus::Complete; } } } /// Total number of unlocked unique keys across all branches. pub fn total_unlocked_count(&self) -> usize { let mut keys: HashSet = HashSet::new(); keys.extend(ALWAYS_UNLOCKED_KEYS.iter().copied()); for branch_def in ALL_BRANCHES { let bp = self.branch_progress(branch_def.id); match bp.status { BranchStatus::InProgress => { if branch_def.id == BranchId::Lowercase { for key in self.lowercase_unlocked_keys() { keys.insert(key); } } else { for (i, level) in branch_def.levels.iter().enumerate() { if i <= bp.current_level { for &key in level.keys { keys.insert(key); } } } } } BranchStatus::Complete => { if branch_def.id == BranchId::Lowercase { for &key in self.primary_letters() { keys.insert(key); } } else { for level in branch_def.levels { for &key in level.keys { keys.insert(key); } } } } _ => {} } } keys.len() } /// Complexity for scoring: total_unlocked / total_unique pub fn complexity(&self) -> f64 { (self.total_unlocked_count() as f64 / self.total_unique_keys as f64).max(0.1) } /// Get all branch definitions with their current progress (for UI). #[allow(dead_code)] pub fn all_branches_with_progress(&self) -> Vec<(&'static BranchDefinition, &BranchProgress)> { ALL_BRANCHES .iter() .map(|def| (def, self.branch_progress(def.id))) .collect() } /// Number of unlocked keys in a branch. pub fn branch_unlocked_count(&self, id: BranchId) -> usize { let def = get_branch_definition(id); let bp = self.branch_progress(id); match bp.status { BranchStatus::Complete => { if id == BranchId::Lowercase { self.primary_letters().len() } else { def.levels.iter().map(|l| l.keys.len()).sum() } } BranchStatus::InProgress => { if id == BranchId::Lowercase { self.lowercase_unlocked_count() } else { def.levels .iter() .enumerate() .filter(|(i, _)| *i <= bp.current_level) .map(|(_, l)| l.keys.len()) .sum() } } _ => 0, } } /// Total keys defined in a branch (across all levels). pub fn branch_total_keys(id: BranchId) -> usize { let def = get_branch_definition(id); def.levels.iter().map(|l| l.keys.len()).sum() } /// Total keys defined in a branch for this tree configuration. pub fn branch_total_keys_for(&self, id: BranchId) -> usize { if id == BranchId::Lowercase { self.primary_letters().len() } else { Self::branch_total_keys(id) } } /// Count of unique confident keys across all branches. pub fn total_confident_keys(&self, stats: &KeyStatsStore) -> usize { let mut keys: HashSet = HashSet::new(); for &ch in ALWAYS_UNLOCKED_KEYS { if stats.get_confidence(ch) >= 1.0 { keys.insert(ch); } } for &ch in self.primary_letters() { if stats.get_confidence(ch) >= 1.0 { keys.insert(ch); } } for branch_def in ALL_BRANCHES { if branch_def.id == BranchId::Lowercase { continue; } for level in branch_def.levels { for &ch in level.keys { if stats.get_confidence(ch) >= 1.0 { keys.insert(ch); } } } } keys.len() } /// Count of confident keys in a branch. pub fn branch_confident_keys(&self, id: BranchId, stats: &KeyStatsStore) -> usize { if id == BranchId::Lowercase { self.primary_letters() .iter() .filter(|&&ch| stats.get_confidence(ch) >= 1.0) .count() } else { let def = get_branch_definition(id); def.levels .iter() .flat_map(|l| l.keys.iter()) .filter(|&&ch| stats.get_confidence(ch) >= 1.0) .count() } } } impl Default for SkillTree { fn default() -> Self { Self::new(SkillTreeProgress::default()) } } #[cfg(test)] mod tests { use super::*; use crate::l10n::language_pack::language_packs; fn make_stats_confident(stats: &mut KeyStatsStore, keys: &[char]) { for &ch in keys { for _ in 0..50 { stats.update_key(ch, 200.0); } } } #[test] fn test_initial_state() { let tree = SkillTree::default(); assert_eq!( *tree.branch_status(BranchId::Lowercase), BranchStatus::InProgress ); assert_eq!( *tree.branch_status(BranchId::Capitals), BranchStatus::Locked ); assert_eq!(*tree.branch_status(BranchId::Numbers), BranchStatus::Locked); } #[test] fn test_total_unique_keys() { let tree = SkillTree::default(); assert_eq!(tree.total_unique_keys, 98); } #[test] fn test_initial_lowercase_unlocked() { let tree = SkillTree::default(); let keys = tree.unlocked_keys(DrillScope::Global); assert_eq!(keys.len(), LOWERCASE_MIN_KEYS + ALWAYS_UNLOCKED_KEYS.len()); assert_eq!(&keys[2..8], &['e', 't', 'a', 'o', 'i', 'n']); assert!(keys.contains(&SPACE)); assert!(keys.contains(&BACKSPACE)); } #[test] fn test_custom_primary_sequence_drives_lowercase_progression() { let tree = SkillTree::new_with_primary_sequence(SkillTreeProgress::default(), "abcde"); let keys = tree.unlocked_keys(DrillScope::Global); // With a shorter primary sequence, all primary letters are immediately unlocked. assert!(keys.contains(&'a')); assert!(keys.contains(&'e')); assert_eq!(tree.primary_letters(), &['a', 'b', 'c', 'd', 'e']); assert_eq!( tree.branch_total_keys_for(BranchId::Lowercase), tree.primary_letters().len() ); } #[test] fn test_lowercase_progressive_unlock() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); // Make initial 6 keys confident make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']); tree.update(&stats, None); // Should unlock 7th key ('s') let keys = tree.unlocked_keys(DrillScope::Global); assert_eq!(keys.len(), 9); assert!(keys.contains(&'s')); } #[test] fn test_lowercase_completion_unlocks_branches() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); // Make all primary letters confident. let all_primary = tree.primary_letters().to_vec(); make_stats_confident(&mut stats, &all_primary); // Need to repeatedly update as each unlock requires all current keys confident for _ in 0..30 { tree.update(&stats, None); } assert_eq!( *tree.branch_status(BranchId::Lowercase), BranchStatus::Complete ); assert_eq!( *tree.branch_status(BranchId::Capitals), BranchStatus::Available ); assert_eq!( *tree.branch_status(BranchId::Numbers), BranchStatus::Available ); assert_eq!( *tree.branch_status(BranchId::ProsePunctuation), BranchStatus::Available ); assert_eq!( *tree.branch_status(BranchId::Whitespace), BranchStatus::Available ); assert_eq!( *tree.branch_status(BranchId::CodeSymbols), BranchStatus::Available ); } #[test] fn test_start_branch() { let mut tree = SkillTree::default(); // Force capitals to Available tree.branch_progress_mut(BranchId::Capitals).status = BranchStatus::Available; tree.start_branch(BranchId::Capitals); assert_eq!( *tree.branch_status(BranchId::Capitals), BranchStatus::InProgress ); assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 0); } #[test] fn test_branch_level_advancement() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); // Set capitals to InProgress at level 0 let bp = tree.branch_progress_mut(BranchId::Capitals); bp.status = BranchStatus::InProgress; bp.current_level = 0; // Make level 1 capitals confident: T I A S W H B M make_stats_confident(&mut stats, &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M']); tree.update(&stats, None); assert_eq!(tree.branch_progress(BranchId::Capitals).current_level, 1); assert_eq!( *tree.branch_status(BranchId::Capitals), BranchStatus::InProgress ); } #[test] fn test_branch_completion() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); let bp = tree.branch_progress_mut(BranchId::Capitals); bp.status = BranchStatus::InProgress; bp.current_level = 0; // Make all capital letter levels confident let all_caps: Vec = ('A'..='Z').collect(); make_stats_confident(&mut stats, &all_caps); // Update multiple times for level advancement for _ in 0..5 { tree.update(&stats, None); } assert_eq!( *tree.branch_status(BranchId::Capitals), BranchStatus::Complete ); } #[test] fn test_shared_key_confidence() { let _tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); // '-' is shared between ProsePunctuation L2 and CodeSymbols L1 // Master it once make_stats_confident(&mut stats, &['-']); // Both branches should see it as confident assert!(stats.get_confidence('-') >= 1.0); } #[test] fn test_focused_key_global() { let tree = SkillTree::default(); let stats = KeyStatsStore::default(); // All keys at 0 confidence, focused should be first in order let focused = tree.focused_key(DrillScope::Global, &stats); assert!(focused.is_some()); // Should be one of the initial 6 lowercase keys assert!( ['e', 't', 'a', 'o', 'i', 'n'].contains(&focused.unwrap()), "focused: {:?}", focused ); } #[test] fn test_focused_key_branch() { let mut tree = SkillTree::default(); let stats = KeyStatsStore::default(); let bp = tree.branch_progress_mut(BranchId::Capitals); bp.status = BranchStatus::InProgress; bp.current_level = 0; let focused = tree.focused_key(DrillScope::Branch(BranchId::Capitals), &stats); assert!(focused.is_some()); // Should be one of level 1 capitals assert!( ['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'].contains(&focused.unwrap()), "focused: {:?}", focused ); } #[test] fn test_complexity_scales() { let tree = SkillTree::default(); let initial_complexity = tree.complexity(); assert!(initial_complexity > 0.0); assert!(initial_complexity < 1.0); // Full unlock should give complexity ~1.0 let mut full_tree = SkillTree::default(); for id in BranchId::all() { let bp = full_tree.branch_progress_mut(*id); bp.status = BranchStatus::Complete; } let full_complexity = full_tree.complexity(); assert!((full_complexity - 1.0).abs() < 0.01); } #[test] fn test_branch_keys_for_drill() { let mut tree = SkillTree::default(); // Set lowercase complete, capitals in progress at level 1 tree.branch_progress_mut(BranchId::Lowercase).status = BranchStatus::Complete; let bp = tree.branch_progress_mut(BranchId::Capitals); bp.status = BranchStatus::InProgress; bp.current_level = 1; let keys = tree.unlocked_keys(DrillScope::Branch(BranchId::Capitals)); // Should include full primary-letter background + Capitals L1 (8) + Capitals L2 (10) assert!(keys.contains(&tree.primary_letters()[0])); // primary-letter background assert!(keys.contains(&'T')); // Capitals L1 assert!(keys.contains(&'J')); // Capitals L2 (current level) assert!(!keys.contains(&'O')); // Capitals L3 (locked) } #[test] fn test_branch_unlocked_count() { let tree = SkillTree::default(); // Lowercase starts InProgress with LOWERCASE_MIN_KEYS assert_eq!( tree.branch_unlocked_count(BranchId::Lowercase), LOWERCASE_MIN_KEYS ); // Locked branches return 0 assert_eq!(tree.branch_unlocked_count(BranchId::Capitals), 0); assert_eq!(tree.branch_unlocked_count(BranchId::Numbers), 0); // InProgress non-lowercase branch let mut tree2 = SkillTree::default(); let bp = tree2.branch_progress_mut(BranchId::Capitals); bp.status = BranchStatus::InProgress; bp.current_level = 1; // Level 0 (8 keys) + Level 1 (10 keys) assert_eq!(tree2.branch_unlocked_count(BranchId::Capitals), 18); // Complete branch returns all keys let mut tree3 = SkillTree::default(); tree3.branch_progress_mut(BranchId::Numbers).status = BranchStatus::Complete; assert_eq!(tree3.branch_unlocked_count(BranchId::Numbers), 10); } #[test] fn test_selectable_branches_bounds() { use crate::ui::components::skill_tree::selectable_branches; let branches = selectable_branches(); assert!(!branches.is_empty()); assert_eq!(branches[0], BranchId::Lowercase); let tree = SkillTree::default(); // Accessing branch_progress for every selectable branch should not panic for &branch_id in &branches { let _ = tree.branch_progress(branch_id); let _ = SkillTree::branch_total_keys(branch_id); let _ = tree.branch_unlocked_count(branch_id); } // Selection at 0 and at max index should be valid assert!(0 < branches.len()); assert!(branches.len() - 1 < branches.len()); } #[test] fn progression_is_monotonic_for_all_language_pack_sequences() { for pack in language_packs() { let mut tree = SkillTree::new_with_primary_sequence( SkillTreeProgress::default(), pack.primary_letter_sequence, ); let primary = tree.primary_letters().to_vec(); assert!( !primary.is_empty(), "primary sequence should be non-empty for {}", pack.language_key ); let mut stats = KeyStatsStore::default(); let mut previous_count = tree.lowercase_unlocked_count(); assert!( previous_count <= primary.len(), "initial unlock count must be bounded for {}", pack.language_key ); // Master keys in configured sequence order and verify unlocked count never decreases. for &ch in &primary { make_stats_confident(&mut stats, &[ch]); for _ in 0..3 { tree.update(&stats, None); let current_count = tree.lowercase_unlocked_count(); assert!( current_count >= previous_count, "unlock count regressed for {}: {} -> {}", pack.language_key, previous_count, current_count ); previous_count = current_count; } } for _ in 0..30 { tree.update(&stats, None); let current_count = tree.lowercase_unlocked_count(); assert!( current_count >= previous_count, "unlock count regressed in completion pass for {}: {} -> {}", pack.language_key, previous_count, current_count ); previous_count = current_count; } assert_eq!( tree.lowercase_unlocked_count(), primary.len(), "all primary letters should unlock for {}", pack.language_key ); assert_eq!( *tree.branch_status(BranchId::Lowercase), BranchStatus::Complete, "lowercase branch should complete for {}", pack.language_key ); } } #[test] fn test_update_returns_newly_unlocked() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); // Make initial 6 keys confident make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i', 'n']); let result = tree.update(&stats, None); // Should unlock 7th key ('s') assert!( result.newly_unlocked.contains(&'s'), "newly_unlocked: {:?}", result.newly_unlocked ); } #[test] fn test_update_returns_newly_mastered() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); // Snapshot before any key stats are added let before_stats = stats.clone(); // Make first 5 keys confident make_stats_confident(&mut stats, &['e', 't', 'a', 'o', 'i']); let result = tree.update(&stats, Some(&before_stats)); // The 5 keys that went from <1.0 to >=1.0 should be in newly_mastered for &ch in &['e', 't', 'a', 'o', 'i'] { assert!( result.newly_mastered.contains(&ch), "expected {} in newly_mastered: {:?}", ch, result.newly_mastered ); } } #[test] fn test_find_key_branch_lowercase() { let result = find_key_branch('e'); assert!(result.is_some()); let (branch, level, pos) = result.unwrap(); assert_eq!(branch.id, BranchId::Lowercase); assert_eq!(level.name_key, "skill_tree.level_frequency_order"); assert_eq!(pos, 1); // 'e' is first in the frequency order } #[test] fn test_find_key_branch_capitals() { let result = find_key_branch('T'); assert!(result.is_some()); let (branch, level, pos) = result.unwrap(); assert_eq!(branch.id, BranchId::Capitals); assert_eq!(level.name_key, "skill_tree.level_common_sentence_capitals"); assert_eq!(pos, 1); // 'T' is first } #[test] fn test_find_key_branch_unknown() { assert!(find_key_branch('\x00').is_none()); } #[test] fn test_branches_newly_available_on_lowercase_complete() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); let all_primary = tree.primary_letters().to_vec(); make_stats_confident(&mut stats, &all_primary); // Run updates to advance through progressive unlock let mut found_available = false; for _ in 0..30 { let result = tree.update(&stats, None); if !result.branches_newly_available.is_empty() { found_available = true; // Should contain exactly the 5 non-lowercase branches assert_eq!(result.branches_newly_available.len(), 5); assert!( !result .branches_newly_available .contains(&BranchId::Lowercase) ); assert!( result .branches_newly_available .contains(&BranchId::Capitals) ); assert!(result.branches_newly_available.contains(&BranchId::Numbers)); assert!( result .branches_newly_available .contains(&BranchId::ProsePunctuation) ); assert!( result .branches_newly_available .contains(&BranchId::Whitespace) ); assert!( result .branches_newly_available .contains(&BranchId::CodeSymbols) ); break; } } assert!(found_available, "branches_newly_available should fire once"); // Second update should NOT have branches_newly_available let result2 = tree.update(&stats, None); assert!( result2.branches_newly_available.is_empty(), "branches_newly_available should be empty on subsequent call" ); } #[test] fn test_branches_newly_completed_on_branch_complete() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); // Set up: capitals InProgress tree.branch_progress_mut(BranchId::Capitals).status = BranchStatus::InProgress; // Make all capital letters confident let all_caps: Vec = ('A'..='Z').collect(); make_stats_confident(&mut stats, &all_caps); // Advance through levels let mut found_complete = false; for _ in 0..5 { let result = tree.update(&stats, None); if result .branches_newly_completed .contains(&BranchId::Capitals) { found_complete = true; break; } } assert!( found_complete, "branches_newly_completed should contain Capitals" ); // Second update should not re-report let result2 = tree.update(&stats, None); assert!( !result2 .branches_newly_completed .contains(&BranchId::Capitals), "should not re-report Capitals as newly completed" ); } #[test] fn test_all_keys_unlocked_fires_once() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); // Set all branches to InProgress at last level with all keys confident // First complete lowercase let all_primary = tree.primary_letters().to_vec(); make_stats_confident(&mut stats, &all_primary); for _ in 0..30 { tree.update(&stats, None); } // Start all branches and make their keys confident for &id in &[ BranchId::Capitals, BranchId::Numbers, BranchId::ProsePunctuation, BranchId::Whitespace, BranchId::CodeSymbols, ] { tree.start_branch(id); let def = get_branch_definition(id); for level in def.levels { make_stats_confident(&mut stats, level.keys); } } // Advance all branches through their levels let mut found_all_unlocked = false; for _ in 0..20 { let result = tree.update(&stats, None); if result.all_keys_unlocked { found_all_unlocked = true; break; } } assert!( found_all_unlocked, "all_keys_unlocked should fire when last key becomes available" ); // Subsequent call should not fire again let result = tree.update(&stats, None); assert!( !result.all_keys_unlocked, "all_keys_unlocked should not fire on subsequent calls" ); } #[test] fn test_all_keys_mastered_fires_once() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); // Make all keys across all branches confident for branch_def in ALL_BRANCHES { for level in branch_def.levels { make_stats_confident(&mut stats, level.keys); } } // Complete lowercase first for _ in 0..30 { tree.update(&stats, None); } // Start and advance all other branches for &id in &[ BranchId::Capitals, BranchId::Numbers, BranchId::ProsePunctuation, BranchId::Whitespace, BranchId::CodeSymbols, ] { tree.start_branch(id); } let mut found_all_mastered = false; for _ in 0..30 { let result = tree.update(&stats, None); if result.all_keys_mastered { found_all_mastered = true; break; } } assert!( found_all_mastered, "all_keys_mastered should fire when all branches complete" ); // Subsequent call should not fire again let result = tree.update(&stats, None); assert!( !result.all_keys_mastered, "all_keys_mastered should not fire on subsequent calls" ); } #[test] fn test_branches_newly_available_only_non_lowercase() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); let all_primary = tree.primary_letters().to_vec(); make_stats_confident(&mut stats, &all_primary); for _ in 0..30 { let result = tree.update(&stats, None); if !result.branches_newly_available.is_empty() { for &id in &result.branches_newly_available { assert_ne!( id, BranchId::Lowercase, "branches_newly_available should not contain Lowercase" ); } break; } } } #[test] fn test_branches_newly_available_canonical_order() { let mut tree = SkillTree::default(); let mut stats = KeyStatsStore::default(); let all_primary = tree.primary_letters().to_vec(); make_stats_confident(&mut stats, &all_primary); for _ in 0..30 { let result = tree.update(&stats, None); if !result.branches_newly_available.is_empty() { // Must be in canonical order: Capitals, Numbers, ProsePunctuation, Whitespace, CodeSymbols assert_eq!( result.branches_newly_available, vec![ BranchId::Capitals, BranchId::Numbers, BranchId::ProsePunctuation, BranchId::Whitespace, BranchId::CodeSymbols, ], "branches_newly_available must be in canonical order" ); break; } } } }