use std::collections::{BTreeSet, HashSet}; use std::fs; use std::path::PathBuf; use std::process::Command; use std::sync::Once; use chrono::Datelike; use keydr::engine::key_stats::{ KeyStatsStore, MASTERY_MAX_ERROR_RATE_EMA, MASTERY_MIN_SAMPLES, MASTERY_MIN_SPEED_CONFIDENCE, }; use keydr::engine::scoring::level_from_score; use keydr::engine::skill_tree::{ALL_BRANCHES, BranchId, BranchStatus, DrillScope, SkillTree}; use keydr::store::json_store::JsonStore; use keydr::store::schema::ExportData; const ALL_PROFILES: &[&str] = &[ "01-brand-new.json", "02-early-lowercase.json", "03-mid-lowercase.json", "03-near-lowercase-complete.json", "04-lowercase-complete.json", "05-multi-branch.json", "06-advanced.json", "07-fully-complete.json", ]; static GENERATE: Once = Once::new(); /// Ensure test-profiles/ exists by running the generator binary (once per test run). fn ensure_profiles_generated() { GENERATE.call_once(|| { let status = Command::new("cargo") .args(["run", "--bin", "generate_test_profiles"]) .status() .expect("failed to run generate_test_profiles"); assert!( status.success(), "generate_test_profiles exited with {status}" ); }); } fn load_profile(name: &str) -> ExportData { ensure_profiles_generated(); let path = format!("test-profiles/{name}"); let json = fs::read_to_string(&path).unwrap_or_else(|e| panic!("Failed to read {path}: {e}")); serde_json::from_str(&json).unwrap_or_else(|e| panic!("Failed to parse {path}: {e}")) } /// Get all keys for levels in completed branches. fn completed_branch_keys(data: &ExportData) -> HashSet { let mut keys = HashSet::new(); for branch_def in ALL_BRANCHES { let bp = data.profile.skill_tree.branches.get(branch_def.id.to_key()); let is_complete = matches!(bp, Some(bp) if bp.status == BranchStatus::Complete); if is_complete { for level in branch_def.levels { for &key in level.keys { keys.insert(key); } } } } keys } /// Get all unlocked keys via SkillTree engine. fn unlocked_keys_set(data: &ExportData) -> HashSet { let tree = SkillTree::new(data.profile.skill_tree.clone()); tree.unlocked_keys(DrillScope::Global).into_iter().collect() } /// Collect keys that are in the current in-progress level (not yet completed). fn in_progress_level_keys(data: &ExportData) -> HashSet { let mut keys = HashSet::new(); for branch_def in ALL_BRANCHES { let bp = match data.profile.skill_tree.branches.get(branch_def.id.to_key()) { Some(bp) => bp, None => continue, }; if bp.status != BranchStatus::InProgress { continue; } if branch_def.id == BranchId::Lowercase { // Lowercase progressive unlock: keys at indices [completed_count..unlocked_count] // current_level = number of keys beyond initial 6 let unlocked_count = 6 + bp.current_level; let all_keys = branch_def.levels[0].keys; // The "frontier" keys that were most recently unlocked and may be partial // For in-progress check, we consider keys that aren't necessarily all mastered yet // The last few unlocked keys are the current learning frontier if unlocked_count <= all_keys.len() { // Keys in the last unlocked batch (the ones most likely < 1.0) let frontier_start = unlocked_count.saturating_sub(4).max(6); for &k in &all_keys[frontier_start..unlocked_count] { keys.insert(k); } } } else if bp.current_level < branch_def.levels.len() { for &k in branch_def.levels[bp.current_level].keys { keys.insert(k); } } } keys } /// Collect keys from ranked drills. fn ranked_drill_keys(data: &ExportData) -> HashSet { let mut keys = HashSet::new(); for drill in &data.drill_history.drills { if drill.ranked { for kt in &drill.per_key_times { keys.insert(kt.key); } } } keys } // ── Per-profile structural validation ──────────────────────────────────── fn assert_profile_valid(name: &str) { let data = load_profile(name); // Invariant #3: total_drills == drills.len() assert_eq!( data.profile.total_drills as usize, data.drill_history.drills.len(), "{name}: total_drills mismatch" ); // Invariant #1: all stats keys are subset of unlocked keys let unlocked = unlocked_keys_set(&data); for &key in data.key_stats.stats.stats.keys() { assert!( unlocked.contains(&key), "{name}: key '{key}' in key_stats is not in the unlocked set" ); } // Invariant #1: all keys in stats have sample_count > 0 for (&key, stat) in &data.key_stats.stats.stats { assert!( stat.sample_count > 0, "{name}: key '{key}' has sample_count 0" ); } // Invariant #2 + #8: all keys in completed branches are sticky-mastered // and completed branches have stats for all their keys. // // Progression invariants are checked against `ranked_key_stats`: that is the // only authoritative store for mastery. Unranked `key_stats` shares the type // but must never carry the sticky bit. let completed_keys = completed_branch_keys(&data); for &key in &completed_keys { assert!( data.ranked_key_stats.stats.stats.contains_key(&key), "{name}: key '{key}' in completed branch has no ranked stats entry" ); let stat = &data.ranked_key_stats.stats.stats[&key]; assert!( stat.mastered, "{name}: key '{key}' in completed branch must have mastered == true, got stat with confidence {} sample_count {} error_rate_ema {}", stat.confidence, stat.sample_count, stat.error_rate_ema ); // Deliberately NOT asserting qualifies_for_mastery() here: mastery is // sticky, so a key that earned it may currently sit below the gate (a // recent error spikes error_rate_ema, a slow stretch drops confidence). // What must hold is that the key accumulated real evidence. assert!( stat.sample_count >= MASTERY_MIN_SAMPLES, "{name}: key '{key}' in completed branch is mastered with only {} samples", stat.sample_count ); } // Only ranked activity may promote mastery. for (&key, stat) in &data.key_stats.stats.stats { assert!( !stat.mastered, "{name}: unranked key_stats entry '{key}' must not carry the sticky mastery bit" ); } // Invariant #9: timestamps are monotonically increasing for i in 1..data.drill_history.drills.len() { assert!( data.drill_history.drills[i].timestamp >= data.drill_history.drills[i - 1].timestamp, "{name}: drill timestamps not monotonic at index {i}" ); } // Invariant #6: drill per_key_times only reference keys from the unlocked set for (i, drill) in data.drill_history.drills.iter().enumerate() { for kt in &drill.per_key_times { assert!( unlocked.contains(&kt.key), "{name}: drill {i} references key '{}' not in unlocked set", kt.key ); } } } #[test] fn profile_01_brand_new_valid() { assert_profile_valid("01-brand-new.json"); } #[test] fn profile_02_early_lowercase_valid() { assert_profile_valid("02-early-lowercase.json"); } #[test] fn profile_03_mid_lowercase_valid() { assert_profile_valid("03-mid-lowercase.json"); } #[test] fn profile_03_near_lowercase_complete_valid() { assert_profile_valid("03-near-lowercase-complete.json"); } #[test] fn profile_04_lowercase_complete_valid() { assert_profile_valid("04-lowercase-complete.json"); } #[test] fn profile_05_multi_branch_valid() { assert_profile_valid("05-multi-branch.json"); } #[test] fn profile_06_advanced_valid() { assert_profile_valid("06-advanced.json"); } #[test] fn profile_07_fully_complete_valid() { assert_profile_valid("07-fully-complete.json"); } // ── Invariant #7: ranked stats presence/population ─────────────────────── #[test] fn profile_01_has_empty_ranked_stats() { let data = load_profile("01-brand-new.json"); assert!( data.ranked_key_stats.stats.stats.is_empty(), "01-brand-new.json: ranked_key_stats should be empty" ); let ranked_count = data .drill_history .drills .iter() .filter(|d| d.ranked) .count(); assert_eq!( ranked_count, 0, "01-brand-new.json: should have no ranked drills" ); } #[test] fn profiles_02_to_07_have_ranked_stats_and_ranked_drills() { for name in &ALL_PROFILES[1..] { let data = load_profile(name); assert!( !data.ranked_key_stats.stats.stats.is_empty(), "{name}: ranked_key_stats should not be empty" ); let ranked_count = data .drill_history .drills .iter() .filter(|d| d.ranked) .count(); assert!( ranked_count > 0, "{name}: expected at least one ranked drill to populate ranked stores" ); } } // ── Invariant #7: ranked stats cover ranked drill keys ─────────────────── #[test] fn ranked_stats_cover_ranked_drill_keys() { for name in &ALL_PROFILES[1..] { let data = load_profile(name); let drill_keys = ranked_drill_keys(&data); let ranked_stat_keys: HashSet = data.ranked_key_stats.stats.stats.keys().copied().collect(); for &key in &drill_keys { assert!( ranked_stat_keys.contains(&key), "{name}: key '{key}' appears in ranked drills but not in ranked_key_stats" ); } } } // ── Invariant #2: in-progress keys have mastered == false ────────────── #[test] fn in_progress_keys_are_not_mastered() { // Profiles 2, 3, 5, 6 have in-progress branches with partial keys for name in &[ "02-early-lowercase.json", "03-mid-lowercase.json", "03-near-lowercase-complete.json", "05-multi-branch.json", "06-advanced.json", ] { let data = load_profile(name); let ip_keys = in_progress_level_keys(&data); // At least some in-progress keys should not yet be sticky-mastered. // Mastery is only authoritative in ranked stats. let partial_count = ip_keys .iter() .filter(|&&k| { data.ranked_key_stats .stats .stats .get(&k) .is_some_and(|s| !s.mastered) }) .count(); assert!( partial_count > 0, "{name}: expected some in-progress keys with mastered == false, \ but all {} in-progress keys are already mastered", ip_keys.len() ); } } // ── Invariant #4: synthetic score produces reasonable level ────────────── #[test] fn synthetic_score_level_in_expected_range() { let expected: &[(&str, u32, u32)] = &[ ("01-brand-new.json", 1, 1), ("02-early-lowercase.json", 1, 3), ("03-mid-lowercase.json", 2, 4), ("03-near-lowercase-complete.json", 3, 5), ("04-lowercase-complete.json", 4, 6), ("05-multi-branch.json", 6, 8), ("06-advanced.json", 10, 14), ("07-fully-complete.json", 16, 20), ]; for &(name, min_level, max_level) in expected { let data = load_profile(name); let level = level_from_score(data.profile.total_score); assert!( level >= min_level && level <= max_level, "{name}: level_from_score({}) = {level}, expected [{min_level}, {max_level}]", data.profile.total_score ); } } // ── Invariant #5: streak/date consistency ──────────────────────────────── /// Compute trailing consecutive-day streak from drill timestamps. fn compute_trailing_streak(data: &ExportData) -> u32 { let drills = &data.drill_history.drills; if drills.is_empty() { return 0; } // Collect unique drill dates (YYYY-MM-DD as ordinal days for easy comparison) let unique_dates: BTreeSet = drills .iter() .map(|d| d.timestamp.num_days_from_ce()) .collect(); let dates_vec: Vec = unique_dates.into_iter().collect(); let last_date = *dates_vec.last().unwrap(); // Count consecutive days backwards from the last date let mut streak = 1u32; for i in (0..dates_vec.len() - 1).rev() { if dates_vec[i] == last_date - streak as i32 { streak += 1; } else { break; } } streak } #[test] fn streak_and_last_practice_date_consistent_with_history() { for name in ALL_PROFILES { let data = load_profile(name); let drills = &data.drill_history.drills; if drills.is_empty() { assert!( data.profile.last_practice_date.is_none(), "{name}: empty history should have no last_practice_date" ); assert_eq!( data.profile.streak_days, 0, "{name}: empty history should have 0 streak" ); } else { // last_practice_date should match the last drill's date let last_drill_date = drills .last() .unwrap() .timestamp .format("%Y-%m-%d") .to_string(); assert_eq!( data.profile.last_practice_date.as_deref(), Some(last_drill_date.as_str()), "{name}: last_practice_date doesn't match last drill timestamp" ); // streak_days should exactly equal trailing consecutive days from history let computed_streak = compute_trailing_streak(&data); assert_eq!( data.profile.streak_days, computed_streak, "{name}: streak_days ({}) doesn't match computed trailing streak ({computed_streak})", data.profile.streak_days ); } } } /// Mastery assertions read `ranked_key_stats` — the authoritative progression /// store. Unranked `key_stats` never carries the sticky bit. fn assert_sticky_mastered(name: &str, key: char, data: &ExportData) { let stat = &data.ranked_key_stats.stats.stats[&key]; assert!( stat.mastered, "{name}: key '{key}' should have mastered == true" ); // Sticky mastery survives later dips, so assert durable evidence rather than // that the key still clears the gate right now. assert!( stat.sample_count >= MASTERY_MIN_SAMPLES, "{name}: key '{key}' is mastered with only {} samples", stat.sample_count ); } fn assert_not_mastered(name: &str, key: char, data: &ExportData) { let stat = &data.ranked_key_stats.stats.stats[&key]; assert!( !stat.mastered, "{name}: key '{key}' should have mastered == false" ); assert!( !stat.qualifies_for_mastery(), "{name}: key '{key}' should miss the mastery gate" ); } // ── Profile-specific mastery semantics ─────────────────────────────────── #[test] fn profile_specific_mastery_semantics() { // Profile 02: s,h,r,d are partial; e,t,a,o,i,n are sticky-mastered. { let data = load_profile("02-early-lowercase.json"); for &k in &['e', 't', 'a', 'o', 'i', 'n'] { assert_sticky_mastered("02", k, &data); } for &k in &['s', 'h', 'r', 'd'] { assert_not_mastered("02", k, &data); } } // Profile 03: first 14 keys mastered; w,f,g,y remain partial. { let data = load_profile("03-mid-lowercase.json"); let all_lc: Vec = "etaoinshrdlcum".chars().collect(); for &k in &all_lc { assert_sticky_mastered("03", k, &data); } for &k in &['w', 'f', 'g', 'y'] { assert_not_mastered("03", k, &data); } } // Profile 03-near: first 24 lowercase keys mastered; one key misses only speed. { let data = load_profile("03-near-lowercase-complete.json"); let stats = &data.ranked_key_stats.stats.stats; let almost_all_lc: Vec = "etaoinshrdlcumwfgypbvkjx".chars().collect(); for &k in &almost_all_lc { assert_sticky_mastered("03-near", k, &data); } let q_stat = &stats[&'q']; assert!( !q_stat.mastered, "03-near: key 'q' should not yet be sticky-mastered" ); assert!( q_stat.sample_count >= MASTERY_MIN_SAMPLES, "03-near: key 'q' should already meet the sample gate" ); assert!( q_stat.error_rate_ema <= MASTERY_MAX_ERROR_RATE_EMA, "03-near: key 'q' should already meet the accuracy gate" ); assert!( q_stat.confidence < MASTERY_MIN_SPEED_CONFIDENCE && q_stat.confidence >= 1.0, "03-near: key 'q' should miss only the speed gate, got confidence {}", q_stat.confidence ); } // Profile 05: capitals L2 partial (J,D,R,C), numbers partial (1,2,3), // punctuation partial (.,','). { let data = load_profile("05-multi-branch.json"); for &k in &['J', 'D', 'R', 'C'] { assert_not_mastered("05", k, &data); } for &k in &['1', '2', '3'] { assert_not_mastered("05", k, &data); } for &k in &['.', ',', '\''] { assert_not_mastered("05", k, &data); } } // Profile 06: code symbols L3 partial (&,|,^,~) { let data = load_profile("06-advanced.json"); for &k in &['&', '|', '^', '~'] { assert_not_mastered("06", k, &data); } assert_sticky_mastered("06", '!', &data); } } /// A branch parked at an in-progress level claims that level is still being /// learned. If its keys were already mastered in the authoritative store, the /// saved progression and the ranked stats would contradict each other. #[test] fn current_level_keys_are_not_mastered_in_ranked_stats() { for name in ALL_PROFILES { let data = load_profile(name); for branch_def in ALL_BRANCHES { // Lowercase unlocks key-by-key rather than by level, so its frontier // legitimately mixes mastered and unmastered keys. if branch_def.id == BranchId::Lowercase { continue; } let Some(bp) = data.profile.skill_tree.branches.get(branch_def.id.to_key()) else { continue; }; if bp.status != BranchStatus::InProgress || bp.current_level >= branch_def.levels.len() { continue; } // Keys shared with an already-completed branch are legitimately mastered. let completed = completed_branch_keys(&data); for &key in branch_def.levels[bp.current_level].keys { if completed.contains(&key) { continue; } if let Some(stat) = data.ranked_key_stats.stats.stats.get(&key) { assert!( !stat.mastered, "{name}: key '{key}' is in {:?}'s current (in-progress) level {} \ but is already mastered in ranked stats", branch_def.id, bp.current_level ); } } } } } /// The persisted stores must be reproducible from the fixture's own history. /// /// Replaying `drill_history` the way the app does has to yield exactly the /// persisted `key_stats`/`ranked_key_stats` — including the sticky `mastered` /// bits — otherwise a fixture encodes a state the engine could never produce. #[test] fn fixture_stats_match_replay_of_their_drill_history() { for name in ALL_PROFILES { let data = load_profile(name); let mut replayed_global = KeyStatsStore { target_cpm: data.key_stats.stats.target_cpm, ..KeyStatsStore::default() }; let mut replayed_ranked = KeyStatsStore { target_cpm: data.ranked_key_stats.stats.target_cpm, ..KeyStatsStore::default() }; for drill in &data.drill_history.drills { for kt in &drill.per_key_times { if kt.correct { replayed_global.update_key(kt.key, kt.time_ms); } else { replayed_global.update_key_error(kt.key); } if drill.ranked { if kt.correct { replayed_ranked.update_key_ranked(kt.key, kt.time_ms); } else { replayed_ranked.update_key_error_ranked(kt.key); } } } } assert_stores_match( name, "ranked_key_stats", &replayed_ranked, &data.ranked_key_stats.stats, ); assert_stores_match(name, "key_stats", &replayed_global, &data.key_stats.stats); } } /// Compare a replayed store against a persisted one. /// /// Discrete state (mastery, counts) must match exactly. Float fields are /// compared with a tolerance: the persisted values made a round trip through /// JSON before being recomputed, so requiring bit-identical results would be /// asserting something about float formatting rather than about the engine. fn assert_stores_match( name: &str, label: &str, replayed: &KeyStatsStore, persisted: &KeyStatsStore, ) { let replayed_keys: BTreeSet = replayed.stats.keys().copied().collect(); let persisted_keys: BTreeSet = persisted.stats.keys().copied().collect(); assert_eq!( replayed_keys, persisted_keys, "{name}: {label} covers different keys than a replay of its drill history" ); for (&key, expected) in &persisted.stats { let actual = &replayed.stats[&key]; assert_eq!( actual.mastered, expected.mastered, "{name}: {label} key '{key}' mastered bit disagrees with replay" ); assert_eq!( actual.sample_count, expected.sample_count, "{name}: {label} key '{key}' sample_count disagrees with replay" ); assert_eq!( actual.error_count, expected.error_count, "{name}: {label} key '{key}' error_count disagrees with replay" ); assert_eq!( actual.total_count, expected.total_count, "{name}: {label} key '{key}' total_count disagrees with replay" ); for (field, a, e) in [ ("confidence", actual.confidence, expected.confidence), ( "filtered_time_ms", actual.filtered_time_ms, expected.filtered_time_ms, ), ("best_time_ms", actual.best_time_ms, expected.best_time_ms), ( "error_rate_ema", actual.error_rate_ema, expected.error_rate_ema, ), ] { assert!( (a - e).abs() <= 1e-9 * e.abs().max(1.0), "{name}: {label} key '{key}' {field} disagrees with replay: {a} vs {e}" ); } } } /// Replaying a fixture's ranked history through the skill tree must reproduce /// the progression state the fixture ships, at least to the extent that every /// completed branch is backed by genuinely mastered keys. #[test] fn fixture_progression_is_backed_by_replayed_mastery() { for name in ALL_PROFILES { let data = load_profile(name); let mut replayed = KeyStatsStore { target_cpm: data.ranked_key_stats.stats.target_cpm, ..KeyStatsStore::default() }; for drill in data.drill_history.drills.iter().filter(|d| d.ranked) { for kt in &drill.per_key_times { if kt.correct { replayed.update_key_ranked(kt.key, kt.time_ms); } else { replayed.update_key_error_ranked(kt.key); } } } for &key in &completed_branch_keys(&data) { assert!( replayed.is_mastered(key), "{name}: key '{key}' is in a completed branch but replaying the fixture's \ ranked history does not earn mastery for it" ); } } } // ── Import via JsonStore ───────────────────────────────────────────────── #[test] fn imports_all_profiles_into_temp_store() { for name in ALL_PROFILES { let data = load_profile(name); let tmp_dir = tempfile::tempdir().unwrap(); let store = JsonStore::with_base_dir(PathBuf::from(tmp_dir.path())).expect("create temp store"); store .import_all(&data) .unwrap_or_else(|e| panic!("{name}: import_all failed: {e}")); // Verify we can reload the imported data let profile = store.load_profile(); assert!(profile.is_some(), "{name}: profile not found after import"); let profile = profile.unwrap(); assert_eq!( profile.total_drills, data.profile.total_drills, "{name}: imported profile total_drills mismatch" ); let key_stats = store.load_key_stats(); assert_eq!( key_stats.stats.stats.len(), data.key_stats.stats.stats.len(), "{name}: imported key_stats entry count mismatch" ); let drill_history = store.load_drill_history(); assert_eq!( drill_history.drills.len(), data.drill_history.drills.len(), "{name}: imported drill_history count mismatch" ); } }