use std::time::Duration;
use crate::commands::{Command, StatsPayload};
use crate::metrics;
use crate::model::{DURATION_OPTIONS, Model, Screen, TestStatus};
use crate::msg::Msg;
fn build_stats_payload(model: &Model) -> StatsPayload {
let correct_words = metrics::count_correct_words(&model.session.words);
let committed_words = metrics::count_committed_words(&model.session.words);
let correct_chars = metrics::count_correct_chars(&model.session.words);
let total_chars = metrics::count_total_chars_typed(&model.session.words);
StatsPayload {
duration_secs: DURATION_OPTIONS[model.config.selected_duration_idx],
wpm: metrics::wpm(correct_words, model.session.elapsed),
raw_wpm: metrics::raw_wpm(committed_words, model.session.elapsed),
accuracy: metrics::accuracy(correct_chars, total_chars),
}
}
pub fn update(model: &mut Model, msg: Msg) -> Command {
match msg {
Msg::Esc => {
model.screen = Screen::Quitting;
}
Msg::Tab => {
if model.session.status != TestStatus::Running {
let next_idx = (model.config.selected_duration_idx + 1) % DURATION_OPTIONS.len();
model.config.selected_duration_idx = next_idx;
model.config.time_limit = Duration::from_secs(DURATION_OPTIONS[next_idx]);
}
model.screen = Screen::Typing;
return Command::GenerateWords {
count: model.config.word_count,
};
}
Msg::Char(c) => {
let session = &mut model.session;
if session.words.is_empty() {
return Command::None;
}
if session.status == TestStatus::Waiting {
session.status = TestStatus::Running;
}
let word = &mut session.words[session.current_word];
if word.typed.len() < word.chars.len() {
word.typed.push(c);
}
let is_last = session.current_word == session.words.len() - 1;
let word_full = session.words[session.current_word].typed.len()
== session.words[session.current_word].chars.len();
if is_last && word_full {
session.words[session.current_word].committed = true;
session.status = TestStatus::Done;
model.screen = Screen::Done;
return Command::SaveStats(build_stats_payload(model));
}
}
Msg::Backspace => {
let session = &mut model.session;
let word = &mut session.words[session.current_word];
if !word.typed.is_empty() {
word.typed.pop();
} else if session.current_word > 0 {
session.current_word -= 1;
session.words[session.current_word].committed = false;
}
}
Msg::Space => {
let session = &mut model.session;
if session.words.is_empty() || session.words[session.current_word].typed.is_empty() {
return Command::None;
}
let is_last = session.current_word == session.words.len() - 1;
session.words[session.current_word].committed = true;
if is_last {
session.status = TestStatus::Done;
model.screen = Screen::Done;
return Command::SaveStats(build_stats_payload(model));
} else {
session.current_word += 1;
}
}
Msg::Tick(elapsed) => {
if model.session.status != TestStatus::Running {
return Command::None;
}
model.session.elapsed = elapsed;
if elapsed >= model.config.time_limit {
model.session.status = TestStatus::Done;
model.screen = Screen::Done;
return Command::SaveStats(build_stats_payload(model));
}
}
}
Command::None
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::model::{Config, SessionState, Word};
fn model_with_words(words: &[&str]) -> Model {
Model {
screen: Screen::Typing,
session: SessionState::new(words.iter().map(|w| Word::new(w)).collect()),
config: Config::default(),
history: Vec::new(),
}
}
#[test]
fn esc_sets_quitting() {
let mut model = model_with_words(&["hello", "world"]);
update(&mut model, Msg::Esc);
assert_eq!(model.screen, Screen::Quitting);
}
#[test]
fn char_transitions_waiting_to_running() {
let mut model = model_with_words(&["hello"]);
assert_eq!(model.session.status, TestStatus::Waiting);
update(&mut model, Msg::Char('h'));
assert_eq!(model.session.status, TestStatus::Running);
}
#[test]
fn char_appends_to_current_word() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Char('e'));
assert_eq!(model.session.words[0].typed, "he");
}
#[test]
fn char_capped_at_word_length() {
let mut model = model_with_words(&["hi"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Char('i'));
update(&mut model, Msg::Char('x')); assert_eq!(model.session.words[0].typed, "hi");
}
#[test]
fn backspace_pops_last_char() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Char('e'));
update(&mut model, Msg::Backspace);
assert_eq!(model.session.words[0].typed, "h");
}
#[test]
fn backspace_at_start_retreats_to_previous_word() {
let mut model = model_with_words(&["hello", "world"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Space); assert_eq!(model.session.current_word, 1);
update(&mut model, Msg::Backspace); assert_eq!(model.session.current_word, 0);
assert!(!model.session.words[0].committed);
}
#[test]
fn backspace_at_first_word_start_is_noop() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Backspace);
assert_eq!(model.session.current_word, 0);
assert!(model.session.words[0].typed.is_empty());
}
#[test]
fn space_advances_to_next_word() {
let mut model = model_with_words(&["hello", "world"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Space);
assert_eq!(model.session.current_word, 1);
assert!(model.session.words[0].committed);
}
#[test]
fn space_on_last_word_sets_done() {
let mut model = model_with_words(&["hi"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Space);
assert_eq!(model.session.status, TestStatus::Done);
assert_eq!(model.screen, Screen::Done);
}
#[test]
fn last_char_of_last_word_auto_ends_test() {
let mut model = model_with_words(&["hi"]);
update(&mut model, Msg::Char('h'));
assert_eq!(model.session.status, TestStatus::Running);
update(&mut model, Msg::Char('i'));
assert_eq!(model.session.status, TestStatus::Done);
assert_eq!(model.screen, Screen::Done);
assert!(model.session.words[0].committed);
}
#[test]
fn last_char_of_last_word_returns_save_stats_command() {
let mut model = model_with_words(&["hi"]);
update(&mut model, Msg::Char('h'));
let cmd = update(&mut model, Msg::Char('i'));
assert!(matches!(cmd, Command::SaveStats(_)));
}
#[test]
fn last_char_on_multi_word_test_auto_ends() {
let mut model = model_with_words(&["go", "hi"]);
update(&mut model, Msg::Char('g'));
update(&mut model, Msg::Space); update(&mut model, Msg::Char('h'));
assert_eq!(model.session.status, TestStatus::Running);
update(&mut model, Msg::Char('i')); assert_eq!(model.session.status, TestStatus::Done);
assert_eq!(model.screen, Screen::Done);
}
#[test]
fn tab_returns_generate_words_command() {
let mut model = model_with_words(&["hello"]);
let cmd = update(&mut model, Msg::Tab);
assert!(matches!(cmd, Command::GenerateWords { .. }));
}
#[test]
fn space_on_empty_typed_is_noop() {
let mut model = model_with_words(&["hello", "world"]);
update(&mut model, Msg::Space);
assert_eq!(model.session.current_word, 0);
assert!(!model.session.words[0].committed);
}
#[test]
fn tab_resets_screen_to_typing() {
let mut model = model_with_words(&["hi"]);
model.screen = Screen::Done;
update(&mut model, Msg::Tab);
assert_eq!(model.screen, Screen::Typing);
}
#[test]
fn tab_while_waiting_cycles_to_next_duration() {
let mut model = model_with_words(&["hello"]);
assert_eq!(model.session.status, TestStatus::Waiting);
update(&mut model, Msg::Tab);
assert_eq!(model.config.selected_duration_idx, 1);
assert_eq!(model.config.time_limit, Duration::from_secs(30));
}
#[test]
fn tab_cycles_through_all_durations() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Tab); assert_eq!(model.config.selected_duration_idx, 1);
update(&mut model, Msg::Tab); assert_eq!(model.config.selected_duration_idx, 2);
update(&mut model, Msg::Tab); assert_eq!(model.config.selected_duration_idx, 0);
assert_eq!(model.config.time_limit, Duration::from_secs(15));
}
#[test]
fn tab_while_running_does_not_cycle() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h')); assert_eq!(model.session.status, TestStatus::Running);
update(&mut model, Msg::Tab);
assert_eq!(model.config.selected_duration_idx, 0);
assert_eq!(model.config.time_limit, Duration::from_secs(15));
}
#[test]
fn tab_while_done_cycles_duration() {
let mut model = model_with_words(&["hi"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Space); assert_eq!(model.screen, Screen::Done);
update(&mut model, Msg::Tab);
assert_eq!(model.config.selected_duration_idx, 1);
assert_eq!(model.config.time_limit, Duration::from_secs(30));
}
#[test]
fn tick_before_running_is_noop() {
let mut model = model_with_words(&["hello"]);
assert_eq!(model.session.status, TestStatus::Waiting);
update(&mut model, Msg::Tick(Duration::from_secs(5)));
assert_eq!(model.session.status, TestStatus::Waiting);
assert_eq!(model.screen, Screen::Typing);
assert_eq!(model.session.elapsed, Duration::ZERO);
}
#[test]
fn tick_updates_elapsed_when_running() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Tick(Duration::from_secs(5)));
assert_eq!(model.session.elapsed, Duration::from_secs(5));
}
#[test]
fn tick_at_time_limit_transitions_to_done() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Tick(Duration::from_secs(15)));
assert_eq!(model.session.status, TestStatus::Done);
assert_eq!(model.screen, Screen::Done);
}
#[test]
fn tick_past_time_limit_transitions_to_done() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Tick(Duration::from_secs(16)));
assert_eq!(model.session.status, TestStatus::Done);
assert_eq!(model.screen, Screen::Done);
}
#[test]
fn tick_after_done_is_noop() {
let mut model = model_with_words(&["hi"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Space);
assert_eq!(model.screen, Screen::Done);
let elapsed_before = model.session.elapsed;
update(&mut model, Msg::Tick(Duration::from_secs(100)));
assert_eq!(model.session.elapsed, elapsed_before);
assert_eq!(model.screen, Screen::Done);
}
#[test]
fn space_on_last_word_returns_save_stats_command() {
let mut model = model_with_words(&["hi"]);
update(&mut model, Msg::Char('h'));
let cmd = update(&mut model, Msg::Space);
assert!(matches!(cmd, Command::SaveStats(_)));
}
#[test]
fn tick_at_time_limit_returns_save_stats_command() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h'));
let cmd = update(&mut model, Msg::Tick(Duration::from_secs(15)));
assert!(matches!(cmd, Command::SaveStats(_)));
}
}
#[cfg(test)]
mod prop_tests {
use std::time::Duration;
use super::*;
use crate::model::{Config, SessionState, Word};
use proptest::prelude::*;
fn arb_msg() -> impl Strategy<Value = Msg> {
prop_oneof![
Just(Msg::Char('a')),
Just(Msg::Char('z')),
Just(Msg::Backspace),
Just(Msg::Space),
Just(Msg::Tick(Duration::ZERO)), ]
}
fn model_with_words(words: &[&str]) -> Model {
Model {
screen: Screen::Typing,
session: SessionState::new(words.iter().map(|w| Word::new(w)).collect()),
config: Config::default(),
history: Vec::new(),
}
}
proptest! {
#[test]
fn current_word_stays_in_bounds(actions in prop::collection::vec(arb_msg(), 0..100)) {
let mut model = model_with_words(&["hello", "world", "test", "kern", "rust"]);
for msg in actions {
update(&mut model, msg);
prop_assert!(model.session.current_word < model.session.words.len());
}
}
#[test]
fn typed_len_never_exceeds_word_len(actions in prop::collection::vec(arb_msg(), 0..100)) {
let mut model = model_with_words(&["hi", "ok", "go", "be", "do"]);
for msg in actions {
update(&mut model, msg);
for word in &model.session.words {
prop_assert!(word.typed.len() <= word.chars.len());
}
}
}
}
}