typetui 0.1.0

A terminal-based typing test.
Documentation
use serde::{Deserialize, Serialize};

#[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;
    }

    pub fn accuracy(&self) -> f64 {
        if self.total_chars == 0 {
            return 100.0;
        }
        let correct = self.total_chars.saturating_sub(self.incorrect_chars);
        (correct as f64 / self.total_chars as f64) * 100.0
    }

    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 = self.total_chars as f64 / 5.0;
        words / elapsed_minutes
    }

    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 = self.total_chars as f64 / 5.0;
        let error_penalty = self.uncorrected_errors as f64 / elapsed_minutes;
        (total_words - error_penalty).max(0.0)
    }

    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,
}

impl TestResult {
    pub fn new(
        mode: &str,
        language: &str,
        duration: u64,
        stats: &Stats,
        elapsed_secs: u64,
    ) -> 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,
        }
    }
}

#[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);
    }
}