Mouse support & branch milestone popups
This commit is contained in:
397
src/app.rs
397
src/app.rs
@@ -84,9 +84,14 @@ pub enum CodeDownloadCompleteAction {
|
||||
ReturnToSettings,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum MilestoneKind {
|
||||
Unlock,
|
||||
Mastery,
|
||||
BranchesAvailable,
|
||||
BranchComplete,
|
||||
AllKeysUnlocked,
|
||||
AllKeysMastered,
|
||||
}
|
||||
|
||||
pub struct KeyMilestonePopup {
|
||||
@@ -94,6 +99,7 @@ pub struct KeyMilestonePopup {
|
||||
pub keys: Vec<char>,
|
||||
pub finger_info: Vec<(char, String)>,
|
||||
pub message: &'static str,
|
||||
pub branch_ids: Vec<BranchId>,
|
||||
}
|
||||
|
||||
const UNLOCK_MESSAGES: &[&str] = &[
|
||||
@@ -1035,6 +1041,7 @@ impl App {
|
||||
keys: update.newly_unlocked,
|
||||
finger_info,
|
||||
message: msg,
|
||||
branch_ids: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1054,6 +1061,55 @@ impl App {
|
||||
keys: update.newly_mastered,
|
||||
finger_info,
|
||||
message: msg,
|
||||
branch_ids: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
// Queue milestone popups for branch/global milestones
|
||||
if !update.branches_newly_available.is_empty() {
|
||||
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||
kind: MilestoneKind::BranchesAvailable,
|
||||
keys: vec![],
|
||||
finger_info: vec![],
|
||||
message: "",
|
||||
branch_ids: update.branches_newly_available,
|
||||
});
|
||||
}
|
||||
|
||||
// Branch complete (excluding Lowercase, since BranchesAvailable covers it)
|
||||
let completed_non_lowercase: Vec<BranchId> = update
|
||||
.branches_newly_completed
|
||||
.iter()
|
||||
.filter(|&&id| id != BranchId::Lowercase)
|
||||
.copied()
|
||||
.collect();
|
||||
if !completed_non_lowercase.is_empty() {
|
||||
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||
kind: MilestoneKind::BranchComplete,
|
||||
keys: vec![],
|
||||
finger_info: vec![],
|
||||
message: "",
|
||||
branch_ids: completed_non_lowercase,
|
||||
});
|
||||
}
|
||||
|
||||
if update.all_keys_unlocked {
|
||||
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||
kind: MilestoneKind::AllKeysUnlocked,
|
||||
keys: vec![],
|
||||
finger_info: vec![],
|
||||
message: "",
|
||||
branch_ids: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
if update.all_keys_mastered {
|
||||
self.milestone_queue.push_back(KeyMilestonePopup {
|
||||
kind: MilestoneKind::AllKeysMastered,
|
||||
keys: vec![],
|
||||
finger_info: vec![],
|
||||
message: "",
|
||||
branch_ids: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2523,6 +2579,7 @@ mod tests {
|
||||
keys: vec!['a'],
|
||||
finger_info: vec![('a', "left pinky".to_string())],
|
||||
message: "Test milestone",
|
||||
branch_ids: vec![],
|
||||
});
|
||||
|
||||
complete_current_drill(&mut app);
|
||||
@@ -2569,4 +2626,344 @@ mod tests {
|
||||
assert_eq!(lowercase_generation_focus(Some('7')), None);
|
||||
assert_eq!(lowercase_generation_focus(None), None);
|
||||
}
|
||||
|
||||
/// Helper: make a key just below mastery in ranked stats.
|
||||
/// Uses timing slightly above target (confidence ≈ 0.98), so one fast drill hit
|
||||
/// will push it over 1.0. target_time ≈ 342.86ms (60000/175 CPM).
|
||||
fn make_key_near_mastery(app: &mut App, ch: char) {
|
||||
for _ in 0..30 {
|
||||
app.ranked_key_stats.update_key(ch, 350.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: make a key fully confident in ranked stats.
|
||||
fn make_key_mastered(app: &mut App, ch: char) {
|
||||
for _ in 0..50 {
|
||||
app.ranked_key_stats.update_key(ch, 200.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: seed a tree where lowercase is nearly complete.
|
||||
/// All 26 lowercase keys are unlocked and at full confidence except 'z'.
|
||||
fn seed_near_complete_lowercase(app: &mut App) {
|
||||
use crate::engine::skill_tree::get_branch_definition;
|
||||
|
||||
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||
let almost_all = &all_lowercase[..25]; // everything except 'z'
|
||||
|
||||
for &ch in almost_all {
|
||||
make_key_mastered(app, ch);
|
||||
}
|
||||
// Make 'z' near-mastery so one drill hit completes it
|
||||
make_key_near_mastery(app, 'z');
|
||||
|
||||
// Advance the skill tree through progressive unlock
|
||||
for _ in 0..30 {
|
||||
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper: set up a drill with a simple target and create events for it.
|
||||
/// Each char in `target` gets a correct keystroke event with fast timing.
|
||||
fn setup_drill_with_events(app: &mut App, target: &str) {
|
||||
use crate::session::input::KeystrokeEvent;
|
||||
|
||||
app.drill = Some(crate::session::drill::DrillState::new(target));
|
||||
let now = Instant::now();
|
||||
|
||||
// Create keystroke events — need at least 2 for windows(2) to produce per_key_times
|
||||
let mut events = Vec::new();
|
||||
for (i, ch) in target.chars().enumerate() {
|
||||
events.push(KeystrokeEvent {
|
||||
expected: ch,
|
||||
actual: ch,
|
||||
timestamp: now + Duration::from_millis(200 * (i as u64)),
|
||||
correct: true,
|
||||
});
|
||||
}
|
||||
app.drill_events = events;
|
||||
|
||||
// Mark drill as complete
|
||||
if let Some(ref mut drill) = app.drill {
|
||||
drill.started_at = Some(now);
|
||||
drill.finished_at = Some(now + Duration::from_millis(200 * (target.len() as u64)));
|
||||
drill.cursor = drill.target.len();
|
||||
drill.input =
|
||||
vec![crate::session::input::CharStatus::Correct; drill.target.len()];
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_drill_lowercase_complete_queue_order() {
|
||||
let mut app = App::new_test();
|
||||
seed_near_complete_lowercase(&mut app);
|
||||
|
||||
// Set up a drill that types 'z' (the last missing key) to push it to mastery
|
||||
setup_drill_with_events(&mut app, "zz");
|
||||
app.milestone_queue.clear();
|
||||
app.finish_drill();
|
||||
|
||||
let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||
|
||||
// Should contain Mastery and BranchesAvailable, but NOT BranchComplete for lowercase
|
||||
assert!(
|
||||
kinds.contains(&&MilestoneKind::Mastery),
|
||||
"Should have Mastery popup, got: {kinds:?}"
|
||||
);
|
||||
assert!(
|
||||
kinds.contains(&&MilestoneKind::BranchesAvailable),
|
||||
"Should have BranchesAvailable popup, got: {kinds:?}"
|
||||
);
|
||||
assert!(
|
||||
!kinds.contains(&&MilestoneKind::BranchComplete),
|
||||
"Should NOT have BranchComplete for lowercase, got: {kinds:?}"
|
||||
);
|
||||
|
||||
// Verify full ordering: any Unlock before Mastery before BranchesAvailable
|
||||
if let Some(unlock_pos) = kinds.iter().position(|k| **k == MilestoneKind::Unlock) {
|
||||
let mastery_pos = kinds
|
||||
.iter()
|
||||
.position(|k| **k == MilestoneKind::Mastery)
|
||||
.unwrap();
|
||||
assert!(
|
||||
unlock_pos < mastery_pos,
|
||||
"Unlock should come before Mastery"
|
||||
);
|
||||
}
|
||||
let mastery_pos = kinds
|
||||
.iter()
|
||||
.position(|k| **k == MilestoneKind::Mastery)
|
||||
.unwrap();
|
||||
let available_pos = kinds
|
||||
.iter()
|
||||
.position(|k| **k == MilestoneKind::BranchesAvailable)
|
||||
.unwrap();
|
||||
assert!(
|
||||
mastery_pos < available_pos,
|
||||
"Mastery should come before BranchesAvailable"
|
||||
);
|
||||
|
||||
// Verify branch_ids in BranchesAvailable popup are in canonical order
|
||||
let branches_popup = app
|
||||
.milestone_queue
|
||||
.iter()
|
||||
.find(|m| m.kind == MilestoneKind::BranchesAvailable)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
branches_popup.branch_ids,
|
||||
vec![
|
||||
BranchId::Capitals,
|
||||
BranchId::Numbers,
|
||||
BranchId::ProsePunctuation,
|
||||
BranchId::Whitespace,
|
||||
BranchId::CodeSymbols,
|
||||
],
|
||||
"BranchesAvailable branch_ids must be in canonical order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_drill_branch_complete_queues_popup() {
|
||||
let mut app = App::new_test();
|
||||
|
||||
// Set capitals to InProgress at last level
|
||||
app.skill_tree
|
||||
.branch_progress_mut(BranchId::Capitals)
|
||||
.status = BranchStatus::InProgress;
|
||||
app.skill_tree
|
||||
.branch_progress_mut(BranchId::Capitals)
|
||||
.current_level = 2; // Last level (3 levels, 0-indexed)
|
||||
|
||||
// Make all capitals except 'Z' fully confident, 'Z' near-mastery
|
||||
for ch in 'A'..='Y' {
|
||||
make_key_mastered(&mut app, ch);
|
||||
}
|
||||
make_key_near_mastery(&mut app, 'Z');
|
||||
|
||||
// Advance tree to reflect current confidence state
|
||||
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||
|
||||
// Set up a drill that types 'Z' to push it to mastery
|
||||
setup_drill_with_events(&mut app, "ZZ");
|
||||
app.milestone_queue.clear();
|
||||
app.finish_drill();
|
||||
|
||||
let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||
|
||||
assert!(
|
||||
kinds.contains(&&MilestoneKind::BranchComplete),
|
||||
"Should have BranchComplete popup, got: {kinds:?}"
|
||||
);
|
||||
|
||||
// The BranchComplete popup should reference Capitals
|
||||
let branch_complete = app
|
||||
.milestone_queue
|
||||
.iter()
|
||||
.find(|m| m.kind == MilestoneKind::BranchComplete)
|
||||
.unwrap();
|
||||
assert!(
|
||||
branch_complete.branch_ids.contains(&BranchId::Capitals),
|
||||
"BranchComplete should reference Capitals"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_drill_all_keys_unlocked_queues_once() {
|
||||
use crate::engine::skill_tree::get_branch_definition;
|
||||
|
||||
let mut app = App::new_test();
|
||||
|
||||
// Complete lowercase
|
||||
let all_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||
for &ch in all_lowercase {
|
||||
make_key_mastered(&mut app, ch);
|
||||
}
|
||||
for _ in 0..30 {
|
||||
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||
}
|
||||
|
||||
// Start all non-lowercase branches. Master all keys through their levels,
|
||||
// but leave CodeSymbols level 2's last key ('~') near-mastery so level 3
|
||||
// keys are not yet unlocked.
|
||||
for &id in &[
|
||||
BranchId::Capitals,
|
||||
BranchId::Numbers,
|
||||
BranchId::ProsePunctuation,
|
||||
BranchId::Whitespace,
|
||||
] {
|
||||
app.skill_tree.start_branch(id);
|
||||
let def = get_branch_definition(id);
|
||||
for level in def.levels {
|
||||
for &ch in level.keys {
|
||||
make_key_mastered(&mut app, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CodeSymbols: master levels 0-1 fully, and level 2 except '~'
|
||||
app.skill_tree.start_branch(BranchId::CodeSymbols);
|
||||
let code_def = get_branch_definition(BranchId::CodeSymbols);
|
||||
for &ch in code_def.levels[0].keys {
|
||||
make_key_mastered(&mut app, ch);
|
||||
}
|
||||
for &ch in code_def.levels[1].keys {
|
||||
make_key_mastered(&mut app, ch);
|
||||
}
|
||||
for &ch in code_def.levels[2].keys {
|
||||
if ch == '~' {
|
||||
make_key_near_mastery(&mut app, ch);
|
||||
} else {
|
||||
make_key_mastered(&mut app, ch);
|
||||
}
|
||||
}
|
||||
// Level 3 keys (@#$%_\`) are not mastered and not yet unlocked
|
||||
|
||||
// Advance all branches through their levels
|
||||
for _ in 0..20 {
|
||||
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||
}
|
||||
|
||||
// Verify CodeSymbols is at level 2 (level 3 keys not yet unlocked)
|
||||
assert_eq!(
|
||||
app.skill_tree
|
||||
.branch_progress(BranchId::CodeSymbols)
|
||||
.current_level,
|
||||
2,
|
||||
"CodeSymbols should be at level 2"
|
||||
);
|
||||
assert!(
|
||||
app.skill_tree.total_unlocked_count() < app.skill_tree.total_unique_keys,
|
||||
"Not all keys should be unlocked yet"
|
||||
);
|
||||
|
||||
// Drill '~' to push it to mastery → advances CodeSymbols to level 3 → all keys unlocked
|
||||
setup_drill_with_events(&mut app, "~~");
|
||||
app.milestone_queue.clear();
|
||||
app.finish_drill();
|
||||
|
||||
let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||
|
||||
// AllKeysUnlocked must be present
|
||||
assert!(
|
||||
kinds.contains(&&MilestoneKind::AllKeysUnlocked),
|
||||
"Should have AllKeysUnlocked popup, got: {kinds:?}"
|
||||
);
|
||||
|
||||
// If AllKeysMastered also fires, AllKeysUnlocked must come first
|
||||
if let (Some(unlocked_pos), Some(mastered_pos)) = (
|
||||
kinds
|
||||
.iter()
|
||||
.position(|k| **k == MilestoneKind::AllKeysUnlocked),
|
||||
kinds
|
||||
.iter()
|
||||
.position(|k| **k == MilestoneKind::AllKeysMastered),
|
||||
) {
|
||||
assert!(
|
||||
unlocked_pos < mastered_pos,
|
||||
"AllKeysUnlocked should come before AllKeysMastered"
|
||||
);
|
||||
}
|
||||
|
||||
// Second drill should NOT re-queue
|
||||
setup_drill_with_events(&mut app, "~~");
|
||||
app.milestone_queue.clear();
|
||||
app.finish_drill();
|
||||
|
||||
let kinds2: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||
assert!(
|
||||
!kinds2.contains(&&MilestoneKind::AllKeysUnlocked),
|
||||
"AllKeysUnlocked should not fire on subsequent drill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_drill_all_keys_mastered_queues_popup() {
|
||||
let mut app = App::new_test();
|
||||
|
||||
// Make every key in every branch mastered except '`'
|
||||
for branch_def in crate::engine::skill_tree::ALL_BRANCHES {
|
||||
for level in branch_def.levels {
|
||||
for &ch in level.keys {
|
||||
if ch == '`' {
|
||||
make_key_near_mastery(&mut app, ch);
|
||||
} else {
|
||||
make_key_mastered(&mut app, ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance lowercase to Complete
|
||||
for _ in 0..30 {
|
||||
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||
}
|
||||
|
||||
// Start all non-lowercase branches
|
||||
for &id in &[
|
||||
BranchId::Capitals,
|
||||
BranchId::Numbers,
|
||||
BranchId::ProsePunctuation,
|
||||
BranchId::Whitespace,
|
||||
BranchId::CodeSymbols,
|
||||
] {
|
||||
app.skill_tree.start_branch(id);
|
||||
}
|
||||
|
||||
// Advance all branches through their levels
|
||||
for _ in 0..30 {
|
||||
app.skill_tree.update(&app.ranked_key_stats, None);
|
||||
}
|
||||
|
||||
// Now '`' is the only key not fully mastered. Drill it.
|
||||
setup_drill_with_events(&mut app, "``");
|
||||
app.milestone_queue.clear();
|
||||
app.finish_drill();
|
||||
|
||||
let kinds: Vec<&MilestoneKind> = app.milestone_queue.iter().map(|m| &m.kind).collect();
|
||||
|
||||
assert!(
|
||||
kinds.contains(&&MilestoneKind::AllKeysMastered),
|
||||
"Should have AllKeysMastered popup, got: {kinds:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ use crate::keyboard::display::{BACKSPACE, SPACE};
|
||||
pub struct SkillTreeUpdate {
|
||||
pub newly_unlocked: Vec<char>,
|
||||
pub newly_mastered: Vec<char>,
|
||||
pub branches_newly_available: Vec<BranchId>,
|
||||
pub branches_newly_completed: Vec<BranchId>,
|
||||
pub all_keys_unlocked: bool,
|
||||
pub all_keys_mastered: bool,
|
||||
}
|
||||
|
||||
// --- Branch ID ---
|
||||
@@ -522,18 +526,32 @@ impl SkillTree {
|
||||
let before_unlocked: HashSet<char> =
|
||||
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
||||
|
||||
// Snapshot branch statuses before any updates
|
||||
let before_branch_statuses: HashMap<BranchId, BranchStatus> = 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 &[
|
||||
BranchId::Capitals,
|
||||
BranchId::Numbers,
|
||||
BranchId::ProsePunctuation,
|
||||
BranchId::Whitespace,
|
||||
BranchId::CodeSymbols,
|
||||
] {
|
||||
for &id in NON_LOWERCASE_BRANCHES {
|
||||
let bp = self.branch_progress_mut(id);
|
||||
if bp.status == BranchStatus::Locked {
|
||||
bp.status = BranchStatus::Available;
|
||||
@@ -541,6 +559,16 @@ impl SkillTree {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect Locked → Available transitions (maintains canonical order)
|
||||
let branches_newly_available: Vec<BranchId> = 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 {
|
||||
@@ -553,6 +581,33 @@ impl SkillTree {
|
||||
self.update_branch_level(branch_def, stats);
|
||||
}
|
||||
|
||||
// Detect branches that became Complete
|
||||
let branches_newly_completed: Vec<BranchId> = 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<char> =
|
||||
self.unlocked_keys(DrillScope::Global).into_iter().collect();
|
||||
@@ -577,6 +632,10 @@ impl SkillTree {
|
||||
SkillTreeUpdate {
|
||||
newly_unlocked,
|
||||
newly_mastered,
|
||||
branches_newly_available,
|
||||
branches_newly_completed,
|
||||
all_keys_unlocked,
|
||||
all_keys_mastered,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,4 +1160,229 @@ mod tests {
|
||||
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_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||
make_stats_confident(&mut stats, all_lowercase);
|
||||
|
||||
// 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<char> = ('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_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||
make_stats_confident(&mut stats, all_lowercase);
|
||||
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_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||
make_stats_confident(&mut stats, all_lowercase);
|
||||
|
||||
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_lowercase = get_branch_definition(BranchId::Lowercase).levels[0].keys;
|
||||
make_stats_confident(&mut stats, all_lowercase);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::event::{self, Event, KeyEvent};
|
||||
use crossterm::event::{self, Event, KeyEvent, MouseEvent};
|
||||
|
||||
pub enum AppEvent {
|
||||
Key(KeyEvent),
|
||||
Mouse(MouseEvent),
|
||||
Tick,
|
||||
Resize(#[allow(dead_code)] u16, #[allow(dead_code)] u16),
|
||||
}
|
||||
@@ -34,6 +35,11 @@ impl EventHandler {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(Event::Mouse(mouse)) => {
|
||||
if tx.send(AppEvent::Mouse(mouse)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if tx.send(AppEvent::Tick).is_err() {
|
||||
|
||||
1433
src/main.rs
1433
src/main.rs
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user