First one-shot pass
This commit is contained in:
58
src/session/input.rs
Normal file
58
src/session/input.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::session::lesson::LessonState;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CharStatus {
|
||||
Correct,
|
||||
Incorrect(char),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KeystrokeEvent {
|
||||
pub expected: char,
|
||||
#[allow(dead_code)]
|
||||
pub actual: char,
|
||||
pub timestamp: Instant,
|
||||
pub correct: bool,
|
||||
}
|
||||
|
||||
pub fn process_char(lesson: &mut LessonState, ch: char) -> Option<KeystrokeEvent> {
|
||||
if lesson.is_complete() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if lesson.started_at.is_none() {
|
||||
lesson.started_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
let expected = lesson.target[lesson.cursor];
|
||||
let correct = ch == expected;
|
||||
|
||||
let event = KeystrokeEvent {
|
||||
expected,
|
||||
actual: ch,
|
||||
timestamp: Instant::now(),
|
||||
correct,
|
||||
};
|
||||
|
||||
if correct {
|
||||
lesson.input.push(CharStatus::Correct);
|
||||
} else {
|
||||
lesson.input.push(CharStatus::Incorrect(ch));
|
||||
}
|
||||
lesson.cursor += 1;
|
||||
|
||||
if lesson.is_complete() {
|
||||
lesson.finished_at = Some(Instant::now());
|
||||
}
|
||||
|
||||
Some(event)
|
||||
}
|
||||
|
||||
pub fn process_backspace(lesson: &mut LessonState) {
|
||||
if lesson.cursor > 0 {
|
||||
lesson.cursor -= 1;
|
||||
lesson.input.pop();
|
||||
}
|
||||
}
|
||||
108
src/session/lesson.rs
Normal file
108
src/session/lesson.rs
Normal file
@@ -0,0 +1,108 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::session::input::CharStatus;
|
||||
|
||||
pub struct LessonState {
|
||||
pub target: Vec<char>,
|
||||
pub input: Vec<CharStatus>,
|
||||
pub cursor: usize,
|
||||
pub started_at: Option<Instant>,
|
||||
pub finished_at: Option<Instant>,
|
||||
}
|
||||
|
||||
impl LessonState {
|
||||
pub fn new(text: &str) -> Self {
|
||||
Self {
|
||||
target: text.chars().collect(),
|
||||
input: Vec::new(),
|
||||
cursor: 0,
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_complete(&self) -> bool {
|
||||
self.cursor >= self.target.len()
|
||||
}
|
||||
|
||||
pub fn elapsed_secs(&self) -> f64 {
|
||||
match (self.started_at, self.finished_at) {
|
||||
(Some(start), Some(end)) => end.duration_since(start).as_secs_f64(),
|
||||
(Some(start), None) => start.elapsed().as_secs_f64(),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn correct_count(&self) -> usize {
|
||||
self.input
|
||||
.iter()
|
||||
.filter(|s| matches!(s, CharStatus::Correct))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn incorrect_count(&self) -> usize {
|
||||
self.input
|
||||
.iter()
|
||||
.filter(|s| matches!(s, CharStatus::Incorrect(_)))
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn wpm(&self) -> f64 {
|
||||
let elapsed = self.elapsed_secs();
|
||||
if elapsed < 0.1 {
|
||||
return 0.0;
|
||||
}
|
||||
let chars = self.correct_count() as f64;
|
||||
(chars / 5.0) / (elapsed / 60.0)
|
||||
}
|
||||
|
||||
pub fn accuracy(&self) -> f64 {
|
||||
let total = self.input.len();
|
||||
if total == 0 {
|
||||
return 100.0;
|
||||
}
|
||||
(self.correct_count() as f64 / total as f64) * 100.0
|
||||
}
|
||||
|
||||
pub fn cpm(&self) -> f64 {
|
||||
let elapsed = self.elapsed_secs();
|
||||
if elapsed < 0.1 {
|
||||
return 0.0;
|
||||
}
|
||||
self.correct_count() as f64 / (elapsed / 60.0)
|
||||
}
|
||||
|
||||
pub fn progress(&self) -> f64 {
|
||||
if self.target.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
self.cursor as f64 / self.target.len() as f64
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_new_lesson() {
|
||||
let lesson = LessonState::new("hello");
|
||||
assert_eq!(lesson.target.len(), 5);
|
||||
assert_eq!(lesson.cursor, 0);
|
||||
assert!(!lesson.is_complete());
|
||||
assert_eq!(lesson.progress(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_accuracy_starts_at_100() {
|
||||
let lesson = LessonState::new("test");
|
||||
assert_eq!(lesson.accuracy(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_lesson_progress() {
|
||||
let lesson = LessonState::new("");
|
||||
assert!(lesson.is_complete());
|
||||
assert_eq!(lesson.progress(), 0.0);
|
||||
}
|
||||
}
|
||||
3
src/session/mod.rs
Normal file
3
src/session/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod input;
|
||||
pub mod lesson;
|
||||
pub mod result;
|
||||
53
src/session/result.rs
Normal file
53
src/session/result.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::session::input::KeystrokeEvent;
|
||||
use crate::session::lesson::LessonState;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct LessonResult {
|
||||
pub wpm: f64,
|
||||
pub cpm: f64,
|
||||
pub accuracy: f64,
|
||||
pub correct: usize,
|
||||
pub incorrect: usize,
|
||||
pub total_chars: usize,
|
||||
pub elapsed_secs: f64,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub per_key_times: Vec<KeyTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct KeyTime {
|
||||
pub key: char,
|
||||
pub time_ms: f64,
|
||||
pub correct: bool,
|
||||
}
|
||||
|
||||
impl LessonResult {
|
||||
pub fn from_lesson(lesson: &LessonState, events: &[KeystrokeEvent]) -> Self {
|
||||
let per_key_times: Vec<KeyTime> = events
|
||||
.windows(2)
|
||||
.map(|pair| {
|
||||
let dt = pair[1].timestamp.duration_since(pair[0].timestamp);
|
||||
KeyTime {
|
||||
key: pair[1].expected,
|
||||
time_ms: dt.as_secs_f64() * 1000.0,
|
||||
correct: pair[1].correct,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
wpm: lesson.wpm(),
|
||||
cpm: lesson.cpm(),
|
||||
accuracy: lesson.accuracy(),
|
||||
correct: lesson.correct_count(),
|
||||
incorrect: lesson.incorrect_count(),
|
||||
total_chars: lesson.target.len(),
|
||||
elapsed_secs: lesson.elapsed_secs(),
|
||||
timestamp: Utc::now(),
|
||||
per_key_times,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user