Create test user profiles to test skill progression

This commit is contained in:
2026-02-28 02:02:39 +00:00
parent da907c0f46
commit ca2a3507f4
7 changed files with 1406 additions and 5 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/target /target
/clones/ /clones/
/test-profiles/

View File

@@ -27,6 +27,10 @@ criterion = { version = "0.5", features = ["html_reports"] }
name = "ngram_benchmarks" name = "ngram_benchmarks"
harness = false harness = false
[[bin]]
name = "generate_test_profiles"
path = "src/bin/generate_test_profiles.rs"
[features] [features]
default = ["network"] default = ["network"]
network = ["reqwest"] network = ["reqwest"]

View File

@@ -0,0 +1,203 @@
# Plan: Create Test User Profiles at Various Skill Tree Progression Levels
## Context
We need importable JSON test profiles representing users at every meaningful stage of skill tree progression. Each profile must have internally consistent key stats, drill history, and skill tree state so the app behaves as if a real user reached that level. The profiles will be used for manual regression testing of UI and logic at each progression stage.
## Design Decisions
- **Ranked mode**: Only profiles 5+ (multi-branch and beyond) include ranked key stats and ranked drills; earlier profiles have empty ranked stats since new users wouldn't have encountered ranked mode yet.
- **`total_score`**: Synthetic plausible value per profile (not replayed from drill history). The goal is UI/progression testing, not scoring fidelity. Score is set to produce a reasonable `level_from_score()` result for each progression stage.
- **Key stats coverage**: `KeyStatsStore` contains only keys that have been practiced (have at least one correct keystroke in drill history). Unlocked-but-unpracticed keys are absent — this is realistic since a freshly unlocked key has no stats. Locked keys are always absent.
- **Fixtures are committed assets**: Generated once, checked into `test-profiles/`. The generator binary is kept for regeneration if schema evolves. Output is deterministic (no RNG — all values computed from formulas).
- **Timestamps**: Monotonically increasing, spaced ~2 minutes apart within a day, spread across days matching `streak_days`. `last_practice_date` derived from the last drill timestamp.
## Consistency Invariants
Every generated profile must satisfy:
1. `KeyStatsStore` contains only practiced keys (subset of unlocked keys). No locked-branch keys ever appear. Every key in stats must have `sample_count > 0`.
2. `KeyStat.confidence >= 1.0` for all keys in completed levels; `< 1.0` for keys in the current in-progress level that are still being learned.
3. `ProfileData.total_drills == drill_history.drills.len()`
4. `ProfileData.total_score` is a plausible synthetic value producing a reasonable level via `level_from_score()`.
5. `ProfileData.streak_days` and `last_practice_date` are consistent with drill timestamps.
6. `DrillResult.per_key_times` only reference keys from the profile's final unlocked set. (Temporal progression fidelity within drill history is a non-goal — all drills use the final-state key pool for simplicity. The goal is testing UI/import behavior at each progression snapshot, not simulating the exact journey.)
7. `ranked_key_stats` is empty (default) for profiles 1-4; populated for profiles 5-7 with stats for keys appearing in ranked drills.
8. Branch marked `Complete` only if all keys in all levels have `confidence >= 1.0`.
9. Drill timestamps are monotonically increasing across the full history.
## Profiles
All files in `test-profiles/` at project root. Each is a valid `ExportData` JSON.
### 1. `01-brand-new.json` — Fresh Start
- **Skill tree**: Lowercase `InProgress` level 0, all others `Locked`
- **Key stats**: Empty
- **Ranked key stats**: Empty
- **Drill history**: Empty (0 drills)
- **Profile**: 0 drills, 0 score, 0 streak
- **Tests**: Initial onboarding, first-run UI, empty dashboard
### 2. `02-early-lowercase.json` — Early Lowercase (10 keys)
- **Skill tree**: Lowercase `InProgress` level 4 (6 base + 4 unlocked = 10 keys: e,t,a,o,i,n,s,h,r,d)
- **Key stats**: e,t,a,o,i,n at confidence >= 1.0 (mastered); s,h,r,d at confidence 0.3-0.7
- **Ranked key stats**: Empty
- **Drill history**: 15 adaptive drills
- **Profile**: 15 drills, synthetic score, 3-day streak
- **Tests**: Progressive lowercase unlock, focused key targeting weak keys, early dashboard
### 3. `03-mid-lowercase.json` — Mid Lowercase (18 keys)
- **Skill tree**: Lowercase `InProgress` level 12 (6 + 12 = 18 keys, through 'y')
- **Key stats**: First 14 keys mastered, next 4 at confidence 0.4-0.8
- **Ranked key stats**: Empty
- **Drill history**: 50 adaptive drills
- **Profile**: 50 drills, synthetic score, 7-day streak
- **Tests**: Many keys unlocked, skill tree partial progress display
Note: Lowercase level semantics — `current_level` = number of keys unlocked beyond the initial 6 (`LOWERCASE_MIN_KEYS`). So level 12 means 18 total keys.
### 4. `04-lowercase-complete.json` — Lowercase Complete
- **Skill tree**: Lowercase `Complete` (level 20, all 26 keys), all others `Available`
- **Key stats**: All 26 lowercase at confidence >= 1.0
- **Ranked key stats**: Empty
- **Drill history**: 100 adaptive drills
- **Profile**: 100 drills, synthetic score, 14-day streak
- **Tests**: Branch completion, all branches showing Available, branch start UI
### 5. `05-multi-branch.json` — Multiple Branches In Progress
- **Skill tree**:
- Lowercase: `Complete`
- Capitals: `InProgress` level 1 (L1 mastered, working on L2 "Name Capitals")
- Numbers: `InProgress` level 0 (working on L1 "Common Digits")
- Prose Punctuation: `InProgress` level 0 (working on L1 "Essential")
- Whitespace: `Available`
- Code Symbols: `Available`
- **Key stats**: All lowercase mastered; T,I,A,S,W,H,B,M mastered; J,D,R,C,E partial; 1,2,3 partial; period/comma/apostrophe partial
- **Ranked key stats**: Some ranked stats for lowercase keys (from ~20 ranked drills)
- **Drill history**: 200 drills (170 adaptive, 10 passage, 20 ranked adaptive)
- **Profile**: 200 drills, synthetic score, 21-day streak
- **Tests**: Multi-branch progress, branch-specific drills, global vs branch focus selection
### 6. `06-advanced.json` — Most Branches Complete
- **Skill tree**:
- Lowercase: `Complete`
- Capitals: `Complete`
- Numbers: `Complete`
- Prose Punctuation: `Complete`
- Whitespace: `Complete`
- Code Symbols: `InProgress` level 2 (L1+L2 done, working on L3 "Logic & Reference")
- **Key stats**: All mastered except Code Symbols L3 (&,|,^,~,!) at partial confidence and L4 absent
- **Ranked key stats**: Substantial ranked stats across all mastered keys
- **Drill history**: 500 drills (350 adaptive, 50 passage, 50 code, 50 ranked)
- **Profile**: 500 drills, synthetic score, 45-day streak, best_streak: 60
- **Tests**: Near-endgame, almost all keys, code symbols progression
### 7. `07-fully-complete.json` — Everything Mastered
- **Skill tree**: ALL branches `Complete`
- **Key stats**: All keys confidence >= 1.0, high sample counts, low error rates
- **Ranked key stats**: Full ranked stats for all keys
- **Drill history**: 800 drills (400 adaptive, 150 passage, 150 code, 100 ranked)
- **Profile**: 800 drills, synthetic score, 90-day streak
- **Tests**: Endgame, all complete, full dashboard, comprehensive ranked data
## Implementation
### File: `src/bin/generate_test_profiles.rs`
A standalone binary that imports keydr crate types and generates all profiles.
#### Helpers
```rust
/// Generate KeyStat with deterministic values derived from target confidence.
/// filtered_time_ms = target_time_ms / confidence
/// best_time_ms = filtered_time_ms * 0.85
/// sample_count and recent_times scaled to confidence level
fn make_key_stat(confidence: f64, sample_count: usize, target_cpm: f64) -> KeyStat
/// Generate a DrillResult with deterministic per_key_times.
/// Keys are chosen from the provided unlocked set.
fn make_drill_result(
wpm: f64, accuracy: f64, char_count: usize,
keys: &[char], timestamp: DateTime<Utc>,
mode: &str, ranked: bool,
) -> DrillResult
/// Wrap all components into ExportData.
fn make_export(
config: Config,
profile: ProfileData,
key_stats: KeyStatsData,
ranked_key_stats: KeyStatsData,
drill_history: DrillHistoryData,
) -> ExportData
/// Generate monotonic timestamps: base_date + day_offset + drill_offset * 2min
fn drill_timestamp(base: DateTime<Utc>, day: u32, drill_in_day: u32) -> DateTime<Utc>
```
#### Profile builders
One function per profile (`build_profile_01()` through `build_profile_07()`) that:
1. Constructs `SkillTreeProgress` with exact branch statuses and levels
2. Builds `KeyStatsStore` with stats only for unlocked/practiced keys
3. Generates drill history with proper timestamps and key references
4. Sets `total_score` to a synthetic plausible value for the progression stage
5. Derives `last_practice_date` and streak from drill timestamps
6. Returns `ExportData`
#### Main
```rust
fn main() {
fs::create_dir_all("test-profiles").unwrap();
for (name, data) in [
("01-brand-new", build_profile_01()),
("02-early-lowercase", build_profile_02()),
// ...
] {
let json = serde_json::to_string_pretty(&data).unwrap();
fs::write(format!("test-profiles/{name}.json"), json).unwrap();
}
}
```
### Key source files referenced
- `src/store/schema.rs` — ExportData, ProfileData, KeyStatsData, DrillHistoryData
- `src/engine/skill_tree.rs` — SkillTreeProgress, BranchProgress, BranchStatus, level definitions, LOWERCASE_MIN_KEYS=6
- `src/engine/key_stats.rs` — KeyStatsStore, KeyStat, DEFAULT_TARGET_CPM=175.0
- `src/session/result.rs` — DrillResult, KeyTime
- `src/config.rs` — Config defaults
- `src/engine/scoring.rs` — compute_score(), level_from_score()
## Verification
### Automated: `tests/test_profile_fixtures.rs`
Integration tests (separate from the generator binary) that for each generated JSON file:
- Deserializes into `ExportData` successfully
- Asserts `total_drills == drills.len()`
- Asserts no locked-branch keys appear in `KeyStatsStore`
- Asserts all keys in completed levels have `confidence >= 1.0`
- Asserts all keys in stats have `sample_count > 0`
- Asserts timestamps are monotonically increasing
- Asserts `ranked_key_stats` is empty for profiles 1-4
- Imports into a temp `JsonStore` via `import_all()` without error
### Manual smoke test per profile
| Profile | Check |
|---------|-------|
| 01 | Dashboard shows level 1, 0 drills, empty skill tree except lowercase InProgress |
| 02 | Skill tree shows 10/26 lowercase keys, focused key is from the weak-key pool (s,h,r,d) |
| 03 | Skill tree shows 18/26 lowercase keys, dashboard stats populated |
| 04 | All 6 branches visible, 5 show "Available", lowercase shows "Complete" |
| 05 | 3 branches InProgress with level indicators, branch drill selector works |
| 06 | 5 branches Complete, Code Symbols shows L3 in progress |
| 07 | All branches Complete, all stats filled, ranked data visible |
### Generation
`cargo run --bin generate_test_profiles` produces 7 files in `test-profiles/`
Generated JSON files are committed to the repo. CI runs fixture validation tests against the committed files (no regeneration step). If the schema changes, the developer reruns the generator manually and commits the updated fixtures.

View File

@@ -0,0 +1,668 @@
use std::collections::HashMap;
use std::fs;
use chrono::{DateTime, TimeZone, Utc};
use keydr::config::Config;
use keydr::engine::key_stats::{KeyStat, KeyStatsStore};
use keydr::engine::skill_tree::{
BranchId, BranchProgress, BranchStatus, SkillTreeProgress, ALL_BRANCHES,
};
use keydr::session::result::{DrillResult, KeyTime};
use keydr::store::schema::{
DrillHistoryData, ExportData, KeyStatsData, ProfileData, EXPORT_VERSION,
};
const SCHEMA_VERSION: u32 = 2;
const TARGET_CPM: f64 = 175.0;
// ── Helpers ──────────────────────────────────────────────────────────────
/// Generate a KeyStat with deterministic values derived from target confidence.
fn make_key_stat(confidence: f64, sample_count: usize) -> KeyStat {
let target_time_ms = 60000.0 / TARGET_CPM; // ~342.86 ms
let filtered_time_ms = target_time_ms / confidence;
let best_time_ms = filtered_time_ms * 0.85;
// Generate recent_times: up to 30 entries near filtered_time_ms
let recent_count = sample_count.min(30);
let recent_times: Vec<f64> = (0..recent_count)
.map(|i| filtered_time_ms + (i as f64 - recent_count as f64 / 2.0) * 2.0)
.collect();
// Error rate scales inversely with confidence
let error_rate = if confidence >= 1.0 {
0.02
} else {
0.1 + (1.0 - confidence) * 0.3
};
let error_count = (sample_count as f64 * error_rate * 0.5) as usize;
let total_count = sample_count + error_count;
KeyStat {
filtered_time_ms,
best_time_ms,
confidence,
sample_count,
recent_times,
error_count,
total_count,
error_rate_ema: error_rate,
}
}
/// Generate monotonic timestamps: base_date + day_offset days + drill_offset * 2min.
fn drill_timestamp(base: DateTime<Utc>, day: u32, drill_in_day: u32) -> DateTime<Utc> {
base + chrono::Duration::days(day as i64)
+ chrono::Duration::seconds(drill_in_day as i64 * 120)
}
/// Generate a DrillResult with deterministic per_key_times.
fn make_drill_result(
wpm: f64,
accuracy: f64,
char_count: usize,
keys: &[char],
timestamp: DateTime<Utc>,
mode: &str,
ranked: bool,
) -> DrillResult {
let cpm = wpm * 5.0;
let incorrect = ((1.0 - accuracy / 100.0) * char_count as f64).round() as usize;
let correct = char_count - incorrect;
let elapsed_secs = char_count as f64 / (cpm / 60.0);
// Generate per_key_times cycling through available keys
let per_key_times: Vec<KeyTime> = (0..char_count)
.map(|i| {
let key = keys[i % keys.len()];
let is_correct = i >= incorrect; // first N are incorrect, rest correct
let time_ms = if is_correct {
60000.0 / cpm + (i as f64 % 7.0) * 3.0
} else {
60000.0 / cpm + 150.0 + (i as f64 % 5.0) * 10.0
};
KeyTime {
key,
time_ms,
correct: is_correct,
}
})
.collect();
DrillResult {
wpm,
cpm,
accuracy,
correct,
incorrect,
total_chars: char_count,
elapsed_secs,
timestamp,
per_key_times,
drill_mode: mode.to_string(),
ranked,
partial: false,
completion_percent: 100.0,
}
}
fn make_skill_tree_progress(branches: Vec<(BranchId, BranchStatus, usize)>) -> SkillTreeProgress {
let mut map = HashMap::new();
for (id, status, level) in branches {
map.insert(
id.to_key().to_string(),
BranchProgress {
status,
current_level: level,
},
);
}
// Fill in any missing branches as Locked
for id in BranchId::all() {
map.entry(id.to_key().to_string())
.or_insert(BranchProgress {
status: BranchStatus::Locked,
current_level: 0,
});
}
SkillTreeProgress { branches: map }
}
/// Fixed exported_at timestamp for deterministic output.
fn fixed_export_timestamp() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2025, 6, 1, 0, 0, 0).unwrap()
}
/// Canonical config with fixed paths for deterministic output across environments.
fn canonical_config() -> Config {
Config {
passage_download_dir: "/tmp/keydr/passages".to_string(),
code_download_dir: "/tmp/keydr/code".to_string(),
..Config::default()
}
}
fn make_export(
profile: ProfileData,
key_stats: KeyStatsStore,
ranked_key_stats: KeyStatsStore,
drill_history: Vec<DrillResult>,
) -> ExportData {
ExportData {
keydr_export_version: EXPORT_VERSION,
exported_at: fixed_export_timestamp(),
config: canonical_config(),
profile,
key_stats: KeyStatsData {
schema_version: SCHEMA_VERSION,
stats: key_stats,
},
ranked_key_stats: KeyStatsData {
schema_version: SCHEMA_VERSION,
stats: ranked_key_stats,
},
drill_history: DrillHistoryData {
schema_version: SCHEMA_VERSION,
drills: drill_history,
},
}
}
/// Get all keys for a branch up to (and including) level_index.
fn branch_keys_up_to(branch_id: BranchId, level_index: usize) -> Vec<char> {
let def = ALL_BRANCHES
.iter()
.find(|b| b.id == branch_id)
.expect("branch not found");
let mut keys = Vec::new();
for (i, level) in def.levels.iter().enumerate() {
if i <= level_index {
keys.extend_from_slice(level.keys);
}
}
keys
}
/// Get all keys for all levels of a branch.
fn branch_all_keys(branch_id: BranchId) -> Vec<char> {
let def = ALL_BRANCHES
.iter()
.find(|b| b.id == branch_id)
.expect("branch not found");
let mut keys = Vec::new();
for level in def.levels {
keys.extend_from_slice(level.keys);
}
keys
}
/// Lowercase keys: first `count` from frequency order.
fn lowercase_keys(count: usize) -> Vec<char> {
let def = ALL_BRANCHES
.iter()
.find(|b| b.id == BranchId::Lowercase)
.unwrap();
def.levels[0].keys[..count].to_vec()
}
/// Base date for all profiles.
fn base_date() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2025, 1, 1, 8, 0, 0).unwrap()
}
/// Generate drill history spread across `streak_days` days.
fn generate_drills(
total: usize,
streak_days: u32,
keys: &[char],
mode_distribution: &[(&str, bool, usize)], // (mode, ranked, count)
base_wpm: f64,
) -> Vec<DrillResult> {
let base = base_date();
let mut drills = Vec::new();
let mut drill_idx = 0usize;
for &(mode, ranked, count) in mode_distribution {
for i in 0..count {
let day = if streak_days > 0 {
(drill_idx as u32 * streak_days) / total as u32
} else {
0
};
let drill_in_day = drill_idx as u32 % 15; // max 15 drills per day spacing
let ts = drill_timestamp(base, day, drill_in_day);
// Vary WPM slightly by index
let wpm = base_wpm + (i as f64 % 10.0) - 5.0;
let accuracy = 92.0 + (i as f64 % 8.0);
let char_count = 80 + (i % 40);
drills.push(make_drill_result(wpm, accuracy, char_count, keys, ts, mode, ranked));
drill_idx += 1;
}
}
// Sort by timestamp to ensure monotonic ordering
drills.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
drills
}
fn last_practice_date_from_drills(drills: &[DrillResult]) -> Option<String> {
drills.last().map(|d| d.timestamp.format("%Y-%m-%d").to_string())
}
// ── Profile Builders ─────────────────────────────────────────────────────
fn build_profile_01() -> ExportData {
let skill_tree = make_skill_tree_progress(vec![
(BranchId::Lowercase, BranchStatus::InProgress, 0),
]);
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
skill_tree,
total_score: 0.0,
total_drills: 0,
streak_days: 0,
best_streak: 0,
last_practice_date: None,
},
KeyStatsStore::default(),
KeyStatsStore::default(),
Vec::new(),
)
}
fn build_profile_02() -> ExportData {
// Lowercase InProgress level 4 => 6 + 4 = 10 keys: e,t,a,o,i,n,s,h,r,d
let skill_tree = make_skill_tree_progress(vec![
(BranchId::Lowercase, BranchStatus::InProgress, 4),
]);
let all_keys = lowercase_keys(10);
let mastered_keys = &all_keys[..6]; // e,t,a,o,i,n
let partial_keys = &all_keys[6..]; // s,h,r,d
let mut stats = KeyStatsStore::default();
for &k in mastered_keys {
stats.stats.insert(k, make_key_stat(1.2, 40));
}
let partial_confidences = [0.3, 0.5, 0.6, 0.7];
for (i, &k) in partial_keys.iter().enumerate() {
stats.stats.insert(k, make_key_stat(partial_confidences[i], 10 + i * 3));
}
let drills = generate_drills(
15, 3, &all_keys,
&[("adaptive", false, 15)],
25.0,
);
// total_score: level_from_score(x) = (x/100).sqrt() => for level 2: score ~400
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
skill_tree,
total_score: 350.0,
total_drills: 15,
streak_days: 3,
best_streak: 3,
last_practice_date: last_practice_date_from_drills(&drills),
},
stats,
KeyStatsStore::default(),
drills,
)
}
fn build_profile_03() -> ExportData {
// Lowercase InProgress level 12 => 6 + 12 = 18 keys through 'y'
let skill_tree = make_skill_tree_progress(vec![
(BranchId::Lowercase, BranchStatus::InProgress, 12),
]);
let all_keys = lowercase_keys(18);
let mastered_keys = &all_keys[..14];
let partial_keys = &all_keys[14..]; // w,f,g,y
let mut stats = KeyStatsStore::default();
for &k in mastered_keys {
stats.stats.insert(k, make_key_stat(1.3, 60));
}
let partial_confidences = [0.4, 0.6, 0.7, 0.8];
for (i, &k) in partial_keys.iter().enumerate() {
stats.stats.insert(k, make_key_stat(partial_confidences[i], 15 + i * 5));
}
let drills = generate_drills(
50, 7, &all_keys,
&[("adaptive", false, 50)],
30.0,
);
// level ~3: score ~900
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
skill_tree,
total_score: 900.0,
total_drills: 50,
streak_days: 7,
best_streak: 7,
last_practice_date: last_practice_date_from_drills(&drills),
},
stats,
KeyStatsStore::default(),
drills,
)
}
fn build_profile_04() -> ExportData {
// Lowercase Complete (level 20), all others Available
let skill_tree = make_skill_tree_progress(vec![
(BranchId::Lowercase, BranchStatus::Complete, 20),
(BranchId::Capitals, BranchStatus::Available, 0),
(BranchId::Numbers, BranchStatus::Available, 0),
(BranchId::ProsePunctuation, BranchStatus::Available, 0),
(BranchId::Whitespace, BranchStatus::Available, 0),
(BranchId::CodeSymbols, BranchStatus::Available, 0),
]);
let all_keys = lowercase_keys(26);
let mut stats = KeyStatsStore::default();
for &k in &all_keys {
stats.stats.insert(k, make_key_stat(1.4, 80));
}
let drills = generate_drills(
100, 14, &all_keys,
&[("adaptive", false, 100)],
35.0,
);
// level ~5: score ~2500
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
skill_tree,
total_score: 2500.0,
total_drills: 100,
streak_days: 14,
best_streak: 14,
last_practice_date: last_practice_date_from_drills(&drills),
},
stats,
KeyStatsStore::default(),
drills,
)
}
fn build_profile_05() -> ExportData {
// Multiple branches in progress
let skill_tree = make_skill_tree_progress(vec![
(BranchId::Lowercase, BranchStatus::Complete, 20),
(BranchId::Capitals, BranchStatus::InProgress, 1),
(BranchId::Numbers, BranchStatus::InProgress, 0),
(BranchId::ProsePunctuation, BranchStatus::InProgress, 0),
(BranchId::Whitespace, BranchStatus::Available, 0),
(BranchId::CodeSymbols, BranchStatus::Available, 0),
]);
let mut stats = KeyStatsStore::default();
// All lowercase mastered
for &k in &lowercase_keys(26) {
stats.stats.insert(k, make_key_stat(1.5, 100));
}
// Capitals L1 mastered: T,I,A,S,W,H,B,M
for &k in &['T', 'I', 'A', 'S', 'W', 'H', 'B', 'M'] {
stats.stats.insert(k, make_key_stat(1.2, 50));
}
// Capitals L2 partial: J,D,R,C,E
let cap_partial = [('J', 0.4), ('D', 0.5), ('R', 0.6), ('C', 0.3), ('E', 0.7)];
for &(k, conf) in &cap_partial {
stats.stats.insert(k, make_key_stat(conf, 15));
}
// Numbers L1 partial: 1,2,3
let num_partial = [('1', 0.4), ('2', 0.5), ('3', 0.3)];
for &(k, conf) in &num_partial {
stats.stats.insert(k, make_key_stat(conf, 12));
}
// Prose punctuation L1 partial: . , '
let punct_partial = [('.', 0.5), (',', 0.4), ('\'', 0.3)];
for &(k, conf) in &punct_partial {
stats.stats.insert(k, make_key_stat(conf, 10));
}
// Build all unlocked keys for drill history
let mut all_unlocked: Vec<char> = lowercase_keys(26);
all_unlocked.extend(branch_keys_up_to(BranchId::Capitals, 1));
all_unlocked.extend(branch_keys_up_to(BranchId::Numbers, 0));
all_unlocked.extend(branch_keys_up_to(BranchId::ProsePunctuation, 0));
let drills = generate_drills(
200, 21, &all_unlocked,
&[
("adaptive", false, 170),
("passage", false, 10),
("adaptive", true, 20),
],
40.0,
);
// Ranked key stats: cover all keys used in ranked drills (all_unlocked)
let mut ranked_stats = KeyStatsStore::default();
for &k in &all_unlocked {
ranked_stats.stats.insert(k, make_key_stat(1.1, 20));
}
// level ~7: score ~5000
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
skill_tree,
total_score: 5000.0,
total_drills: 200,
streak_days: 21,
best_streak: 21,
last_practice_date: last_practice_date_from_drills(&drills),
},
stats,
ranked_stats,
drills,
)
}
fn build_profile_06() -> ExportData {
// Most branches complete, Code Symbols InProgress level 2
let skill_tree = make_skill_tree_progress(vec![
(BranchId::Lowercase, BranchStatus::Complete, 20),
(BranchId::Capitals, BranchStatus::Complete, 3),
(BranchId::Numbers, BranchStatus::Complete, 2),
(BranchId::ProsePunctuation, BranchStatus::Complete, 3),
(BranchId::Whitespace, BranchStatus::Complete, 2),
(BranchId::CodeSymbols, BranchStatus::InProgress, 2),
]);
let mut stats = KeyStatsStore::default();
// All lowercase mastered
for &k in &lowercase_keys(26) {
stats.stats.insert(k, make_key_stat(1.6, 200));
}
// All capitals mastered
for &k in &branch_all_keys(BranchId::Capitals) {
stats.stats.insert(k, make_key_stat(1.4, 120));
}
// All numbers mastered
for &k in &branch_all_keys(BranchId::Numbers) {
stats.stats.insert(k, make_key_stat(1.3, 100));
}
// All prose punctuation mastered
for &k in &branch_all_keys(BranchId::ProsePunctuation) {
stats.stats.insert(k, make_key_stat(1.3, 90));
}
// All whitespace mastered
for &k in &branch_all_keys(BranchId::Whitespace) {
stats.stats.insert(k, make_key_stat(1.2, 80));
}
// Code Symbols L1 + L2 mastered
for &k in &branch_keys_up_to(BranchId::CodeSymbols, 1) {
stats.stats.insert(k, make_key_stat(1.2, 60));
}
// Code Symbols L3 partial: &,|,^,~
// Note: '!' is shared with ProsePunctuation L3 (Complete), so it must be mastered
let code_partial = [('&', 0.4), ('|', 0.5), ('^', 0.3), ('~', 0.4)];
for &(k, conf) in &code_partial {
stats.stats.insert(k, make_key_stat(conf, 15));
}
// '!' is mastered (shared with completed ProsePunctuation)
stats.stats.insert('!', make_key_stat(1.2, 60));
// All unlocked keys for drills
let mut all_unlocked: Vec<char> = lowercase_keys(26);
all_unlocked.extend(branch_all_keys(BranchId::Capitals));
all_unlocked.extend(branch_all_keys(BranchId::Numbers));
all_unlocked.extend(branch_all_keys(BranchId::ProsePunctuation));
all_unlocked.extend(branch_all_keys(BranchId::Whitespace));
all_unlocked.extend(branch_keys_up_to(BranchId::CodeSymbols, 2));
let drills = generate_drills(
500, 45, &all_unlocked,
&[
("adaptive", false, 350),
("passage", false, 50),
("code", false, 50),
("adaptive", true, 50),
],
50.0,
);
// Ranked key stats: cover all keys used in ranked drills (all_unlocked)
let mut ranked_stats = KeyStatsStore::default();
for &k in &all_unlocked {
ranked_stats.stats.insert(k, make_key_stat(1.1, 30));
}
// level ~12: score ~15000
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
skill_tree,
total_score: 15000.0,
total_drills: 500,
streak_days: 45,
best_streak: 60,
last_practice_date: last_practice_date_from_drills(&drills),
},
stats,
ranked_stats,
drills,
)
}
fn build_profile_07() -> ExportData {
// Everything complete
let skill_tree = make_skill_tree_progress(vec![
(BranchId::Lowercase, BranchStatus::Complete, 20),
(BranchId::Capitals, BranchStatus::Complete, 3),
(BranchId::Numbers, BranchStatus::Complete, 2),
(BranchId::ProsePunctuation, BranchStatus::Complete, 3),
(BranchId::Whitespace, BranchStatus::Complete, 2),
(BranchId::CodeSymbols, BranchStatus::Complete, 4),
]);
let mut stats = KeyStatsStore::default();
// All keys mastered with high sample counts
for &k in &lowercase_keys(26) {
stats.stats.insert(k, make_key_stat(1.8, 400));
}
for &k in &branch_all_keys(BranchId::Capitals) {
stats.stats.insert(k, make_key_stat(1.5, 200));
}
for &k in &branch_all_keys(BranchId::Numbers) {
stats.stats.insert(k, make_key_stat(1.4, 180));
}
for &k in &branch_all_keys(BranchId::ProsePunctuation) {
stats.stats.insert(k, make_key_stat(1.4, 160));
}
for &k in &branch_all_keys(BranchId::Whitespace) {
stats.stats.insert(k, make_key_stat(1.3, 140));
}
for &k in &branch_all_keys(BranchId::CodeSymbols) {
stats.stats.insert(k, make_key_stat(1.3, 120));
}
// All keys for drills
let mut all_keys: Vec<char> = lowercase_keys(26);
all_keys.extend(branch_all_keys(BranchId::Capitals));
all_keys.extend(branch_all_keys(BranchId::Numbers));
all_keys.extend(branch_all_keys(BranchId::ProsePunctuation));
all_keys.extend(branch_all_keys(BranchId::Whitespace));
all_keys.extend(branch_all_keys(BranchId::CodeSymbols));
let drills = generate_drills(
800, 90, &all_keys,
&[
("adaptive", false, 400),
("passage", false, 150),
("code", false, 150),
("adaptive", true, 100),
],
60.0,
);
// Full ranked stats
let mut ranked_stats = KeyStatsStore::default();
for &k in &all_keys {
ranked_stats.stats.insert(k, make_key_stat(1.4, 80));
}
// level ~18: score ~35000
make_export(
ProfileData {
schema_version: SCHEMA_VERSION,
skill_tree,
total_score: 35000.0,
total_drills: 800,
streak_days: 90,
best_streak: 90,
last_practice_date: last_practice_date_from_drills(&drills),
},
stats,
ranked_stats,
drills,
)
}
// ── Main ─────────────────────────────────────────────────────────────────
fn main() {
fs::create_dir_all("test-profiles").unwrap();
let profiles: Vec<(&str, ExportData)> = vec![
("01-brand-new", build_profile_01()),
("02-early-lowercase", build_profile_02()),
("03-mid-lowercase", build_profile_03()),
("04-lowercase-complete", build_profile_04()),
("05-multi-branch", build_profile_05()),
("06-advanced", build_profile_06()),
("07-fully-complete", build_profile_07()),
];
for (name, data) in &profiles {
let json = serde_json::to_string_pretty(data).unwrap();
let path = format!("test-profiles/{name}.json");
fs::write(&path, &json).unwrap();
println!("Wrote {path} ({} bytes)", json.len());
}
println!("\nGenerated {} test profiles.", profiles.len());
}

View File

@@ -4,15 +4,15 @@
// Most code is only exercised through the binary, so suppress dead_code warnings. // Most code is only exercised through the binary, so suppress dead_code warnings.
#![allow(dead_code)] #![allow(dead_code)]
// Public: used directly by benchmarks // Public: used by benchmarks and the generate_test_profiles binary
pub mod config;
pub mod engine; pub mod engine;
pub mod keyboard;
pub mod session; pub mod session;
pub mod store;
// Private: required transitively by engine/session (won't compile without them) // Private: required transitively by engine/session (won't compile without them)
mod app; mod app;
mod config;
mod event; mod event;
mod generator; mod generator;
mod keyboard;
mod store;
mod ui; mod ui;

View File

@@ -24,7 +24,7 @@ impl JsonStore {
Ok(Self { base_dir }) Ok(Self { base_dir })
} }
#[cfg(test)] #[allow(dead_code)] // Used by integration tests
pub fn with_base_dir(base_dir: PathBuf) -> Result<Self> { pub fn with_base_dir(base_dir: PathBuf) -> Result<Self> {
fs::create_dir_all(&base_dir)?; fs::create_dir_all(&base_dir)?;
Ok(Self { base_dir }) Ok(Self { base_dir })

View File

@@ -0,0 +1,525 @@
use std::collections::{BTreeSet, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Once;
use chrono::Datelike;
use keydr::engine::scoring::level_from_score;
use keydr::engine::skill_tree::{
BranchId, BranchStatus, DrillScope, SkillTree, ALL_BRANCHES,
};
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",
"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(|| {
if Path::new("test-profiles/07-fully-complete.json").exists() {
return;
}
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<char> {
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<char> {
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<char> {
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<char> {
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 have confidence >= 1.0
// and completed branches have stats for all their keys
let completed_keys = completed_branch_keys(&data);
for &key in &completed_keys {
assert!(
data.key_stats.stats.stats.contains_key(&key),
"{name}: key '{key}' in completed branch has no stats entry"
);
let stat = &data.key_stats.stats.stats[&key];
assert!(
stat.confidence >= 1.0,
"{name}: key '{key}' in completed branch has confidence {} < 1.0",
stat.confidence
);
}
// 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_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 empty/populated ───────────────────────────
#[test]
fn profiles_01_to_04_have_empty_ranked_stats() {
for name in &ALL_PROFILES[..4] {
let data = load_profile(name);
assert!(
data.ranked_key_stats.stats.stats.is_empty(),
"{name}: ranked_key_stats should be empty"
);
// Also verify no ranked drills exist
let ranked_count = data
.drill_history
.drills
.iter()
.filter(|d| d.ranked)
.count();
assert_eq!(ranked_count, 0, "{name}: should have no ranked drills");
}
}
#[test]
fn profiles_05_to_07_have_ranked_stats() {
for name in &ALL_PROFILES[4..] {
let data = load_profile(name);
assert!(
!data.ranked_key_stats.stats.stats.is_empty(),
"{name}: ranked_key_stats should not be empty"
);
}
}
// ── Invariant #7: ranked stats cover ranked drill keys ───────────────────
#[test]
fn ranked_stats_cover_ranked_drill_keys() {
for name in &ALL_PROFILES[4..] {
let data = load_profile(name);
let drill_keys = ranked_drill_keys(&data);
let ranked_stat_keys: HashSet<char> =
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 confidence < 1.0 ────────────────
#[test]
fn in_progress_keys_have_partial_confidence() {
// Profiles 2, 3, 5, 6 have in-progress branches with partial keys
for name in &[
"02-early-lowercase.json",
"03-mid-lowercase.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 have confidence < 1.0
let partial_count = ip_keys
.iter()
.filter(|&&k| {
data.key_stats
.stats
.stats
.get(&k)
.is_some_and(|s| s.confidence < 1.0)
})
.count();
assert!(
partial_count > 0,
"{name}: expected some in-progress keys with confidence < 1.0, \
but all {} in-progress keys are 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),
("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<i32> = drills
.iter()
.map(|d| d.timestamp.num_days_from_ce())
.collect();
let dates_vec: Vec<i32> = 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
);
}
}
}
// ── Profile-specific confidence bands ────────────────────────────────────
#[test]
fn profile_specific_confidence_bands() {
// Profile 02: s,h,r,d should be partial (0.3-0.7); e,t,a,o,i,n should be mastered
{
let data = load_profile("02-early-lowercase.json");
let stats = &data.key_stats.stats.stats;
for &k in &['e', 't', 'a', 'o', 'i', 'n'] {
let conf = stats[&k].confidence;
assert!(conf >= 1.0, "02: key '{k}' should be mastered, got {conf}");
}
for &k in &['s', 'h', 'r', 'd'] {
let conf = stats[&k].confidence;
assert!(
(0.2..1.0).contains(&conf),
"02: key '{k}' should be partial (0.2-1.0), got {conf}"
);
}
}
// Profile 03: first 14 keys mastered, w,f,g,y partial (0.4-0.8)
{
let data = load_profile("03-mid-lowercase.json");
let stats = &data.key_stats.stats.stats;
let all_lc: Vec<char> = "etaoinshrdlcum".chars().collect();
for &k in &all_lc {
let conf = stats[&k].confidence;
assert!(conf >= 1.0, "03: key '{k}' should be mastered, got {conf}");
}
for &k in &['w', 'f', 'g', 'y'] {
let conf = stats[&k].confidence;
assert!(
(0.3..1.0).contains(&conf),
"03: key '{k}' should be partial (0.3-1.0), got {conf}"
);
}
}
// Profile 05: capitals L2 partial (J,D,R,C,E), numbers partial (1,2,3),
// punctuation partial (.,',')
{
let data = load_profile("05-multi-branch.json");
let stats = &data.key_stats.stats.stats;
for &k in &['J', 'D', 'R', 'C'] {
let conf = stats[&k].confidence;
assert!(
(0.2..1.0).contains(&conf),
"05: key '{k}' should be partial, got {conf}"
);
}
for &k in &['1', '2', '3'] {
let conf = stats[&k].confidence;
assert!(
(0.2..1.0).contains(&conf),
"05: key '{k}' should be partial, got {conf}"
);
}
for &k in &['.', ',', '\''] {
let conf = stats[&k].confidence;
assert!(
(0.2..1.0).contains(&conf),
"05: key '{k}' should be partial, got {conf}"
);
}
}
// Profile 06: code symbols L3 partial (&,|,^,~)
{
let data = load_profile("06-advanced.json");
let stats = &data.key_stats.stats.stats;
for &k in &['&', '|', '^', '~'] {
let conf = stats[&k].confidence;
assert!(
(0.2..1.0).contains(&conf),
"06: key '{k}' should be partial, got {conf}"
);
}
// '!' is shared with completed ProsePunctuation, must be mastered
let bang_conf = stats[&'!'].confidence;
assert!(
bang_conf >= 1.0,
"06: key '!' should be mastered (shared with complete branch), got {bang_conf}"
);
}
}
// ── 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"
);
}
}