use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatsSnapshot {
pub elapsed_secs: u64,
pub wpm: f64,
pub raw_wpm: f64,
pub accuracy: f64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Stats {
pub total_chars: u32,
pub correct_chars: u32,
pub incorrect_chars: u32,
pub backspaces: u32,
pub uncorrected_errors: u32,
}
impl Stats {
pub fn record_char(&mut self, correct: bool) {
self.total_chars += 1;
if correct {
self.correct_chars += 1;
} else {
self.incorrect_chars += 1;
}
}
pub fn record_backspace(&mut self) {
self.backspaces += 1;
}
pub fn record_uncorrected_error(&mut self) {
self.uncorrected_errors += 1;
}
#[must_use]
pub fn accuracy(&self) -> f64 {
if self.total_chars == 0 {
return 100.0;
}
let correct = self.total_chars.saturating_sub(self.incorrect_chars);
(f64::from(correct) / f64::from(self.total_chars)) * 100.0
}
#[must_use]
pub fn raw_wpm(&self, elapsed_secs: u64) -> f64 {
if elapsed_secs == 0 {
return 0.0;
}
let elapsed_minutes = elapsed_secs as f64 / 60.0;
let words = f64::from(self.total_chars) / 5.0;
words / elapsed_minutes
}
#[must_use]
pub fn net_wpm(&self, elapsed_secs: u64) -> f64 {
if elapsed_secs == 0 {
return 0.0;
}
let elapsed_minutes = elapsed_secs as f64 / 60.0;
let total_words = f64::from(self.total_chars) / 5.0;
let error_penalty = f64::from(self.uncorrected_errors) / elapsed_minutes;
(total_words - error_penalty).max(0.0)
}
#[must_use]
pub fn wpm(&self, elapsed_secs: u64) -> f64 {
self.net_wpm(elapsed_secs)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestResult {
pub timestamp: String,
pub mode: String,
pub language: String,
pub duration: u64,
pub wpm: f64,
pub raw_wpm: f64,
pub accuracy: f64,
pub total_chars: u32,
pub errors: u32,
#[serde(default)]
pub time_series: Vec<StatsSnapshot>,
}
impl TestResult {
#[must_use]
pub fn new(
mode: &str,
language: &str,
duration: u64,
stats: &Stats,
elapsed_secs: u64,
time_series: Vec<StatsSnapshot>,
) -> Self {
Self {
timestamp: jiff::Zoned::now().to_string(),
mode: mode.to_string(),
language: language.to_string(),
duration,
wpm: stats.wpm(elapsed_secs),
raw_wpm: stats.raw_wpm(elapsed_secs),
accuracy: stats.accuracy(),
total_chars: stats.total_chars,
errors: stats.incorrect_chars,
time_series,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_accuracy() {
let mut stats = Stats::default();
assert_eq!(stats.accuracy(), 100.0);
stats.record_char(true);
stats.record_char(true);
stats.record_char(false);
assert_eq!(stats.accuracy(), 66.66666666666666);
}
#[test]
fn test_wpm_calculation() {
let mut stats = Stats::default();
for _ in 0..50 {
stats.record_char(true);
}
assert_eq!(stats.raw_wpm(60), 10.0);
}
#[test]
fn test_net_wpm_with_errors() {
let mut stats = Stats::default();
for _ in 0..40 {
stats.record_char(true);
}
for _ in 0..10 {
stats.record_char(false);
}
stats.uncorrected_errors = 5;
let net = stats.net_wpm(60);
assert_eq!(net, 5.0);
}
}