use std::time::Duration;
use crate::commands::{Command, StatsPayload};
use crate::metrics;
use crate::model::{DURATION_OPTIONS, Model, Screen, TestMode, TestStatus, WORD_COUNT_OPTIONS};
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);
let duration_secs = match model.config.test_mode {
TestMode::Time => DURATION_OPTIONS[model.config.selected_duration_idx],
TestMode::Words => model.session.elapsed.as_secs(),
};
StatsPayload {
duration_secs,
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 => {
model.screen = Screen::Typing;
return Command::GenerateWords {
count: model.config.initial_word_count(),
};
}
Msg::Char(c) => {
if model.session.words.is_empty() {
return Command::None;
}
if model.session.status == TestStatus::Waiting {
model.session.status = TestStatus::Running;
}
{
let word = &mut model.session.words[model.session.current_word];
if word.typed.len() < word.chars.len() {
word.typed.push(c);
}
}
let is_last = model.session.current_word == model.session.words.len() - 1;
let word_full = model.session.words[model.session.current_word].typed.len()
== model.session.words[model.session.current_word].chars.len();
if is_last && word_full {
match model.config.test_mode {
TestMode::Words => {
model.session.words[model.session.current_word].committed = true;
model.session.status = TestStatus::Done;
model.screen = Screen::Done;
return Command::SaveStats(build_stats_payload(model));
}
TestMode::Time => {
model.session.words[model.session.current_word].committed = true;
return Command::AppendWords { count: 1 };
}
}
}
}
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 => {
if model.session.words.is_empty()
|| model.session.words[model.session.current_word]
.typed
.is_empty()
|| model.session.words[model.session.current_word].committed
{
return Command::None;
}
let is_last = model.session.current_word == model.session.words.len() - 1;
model.session.words[model.session.current_word].committed = true;
if is_last {
match model.config.test_mode {
TestMode::Words => {
model.session.status = TestStatus::Done;
model.screen = Screen::Done;
return Command::SaveStats(build_stats_payload(model));
}
TestMode::Time => {
return Command::AppendWords { count: 1 };
}
}
} else {
model.session.current_word += 1;
if matches!(model.config.test_mode, TestMode::Time) {
return Command::AppendWords { count: 1 };
}
}
}
Msg::Tick(elapsed) => {
if model.session.status != TestStatus::Running {
return Command::None;
}
model.session.elapsed = elapsed;
if matches!(model.config.test_mode, TestMode::Time)
&& elapsed >= model.config.time_limit
{
model.session.status = TestStatus::Done;
model.screen = Screen::Done;
return Command::SaveStats(build_stats_payload(model));
}
}
Msg::ShiftTab => {
if model.session.status == TestStatus::Running {
return Command::None;
}
model.config.test_mode = match model.config.test_mode {
TestMode::Time => TestMode::Words,
TestMode::Words => TestMode::Time,
};
model.screen = Screen::Typing;
return Command::GenerateWords {
count: model.config.initial_word_count(),
};
}
Msg::Right => {
if model.session.status == TestStatus::Running {
return Command::None;
}
match model.config.test_mode {
TestMode::Time => {
let next = (model.config.selected_duration_idx + 1) % DURATION_OPTIONS.len();
model.config.selected_duration_idx = next;
model.config.time_limit = Duration::from_secs(DURATION_OPTIONS[next]);
}
TestMode::Words => {
let next =
(model.config.selected_word_count_idx + 1) % WORD_COUNT_OPTIONS.len();
model.config.selected_word_count_idx = next;
model.config.word_count = WORD_COUNT_OPTIONS[next];
}
}
model.screen = Screen::Typing;
return Command::GenerateWords {
count: model.config.initial_word_count(),
};
}
Msg::Left => {
if model.session.status == TestStatus::Running {
return Command::None;
}
match model.config.test_mode {
TestMode::Time => {
let prev = (model.config.selected_duration_idx + DURATION_OPTIONS.len() - 1)
% DURATION_OPTIONS.len();
model.config.selected_duration_idx = prev;
model.config.time_limit = Duration::from_secs(DURATION_OPTIONS[prev]);
}
TestMode::Words => {
let prev = (model.config.selected_word_count_idx + WORD_COUNT_OPTIONS.len()
- 1)
% WORD_COUNT_OPTIONS.len();
model.config.selected_word_count_idx = prev;
model.config.word_count = WORD_COUNT_OPTIONS[prev];
}
}
model.screen = Screen::Typing;
return Command::GenerateWords {
count: model.config.initial_word_count(),
};
}
}
Command::None
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
use crate::model::{
Config, DURATION_OPTIONS, Screen, SessionState, TestMode, TestStatus, WORD_COUNT_OPTIONS,
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"]);
model.config.test_mode = TestMode::Words;
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"]);
model.config.test_mode = TestMode::Words;
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"]);
model.config.test_mode = TestMode::Words;
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"]);
model.config.test_mode = TestMode::Words;
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_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 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"]);
model.config.test_mode = TestMode::Words;
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"]);
model.config.test_mode = TestMode::Words;
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(_)));
}
#[test]
fn right_cycles_duration_forward() {
let mut model = model_with_words(&["hello"]);
assert_eq!(model.session.status, TestStatus::Waiting);
update(&mut model, Msg::Right);
assert_eq!(model.config.selected_duration_idx, 1);
assert_eq!(model.config.time_limit, Duration::from_secs(30));
}
#[test]
fn right_cycles_duration_wraps() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Right); update(&mut model, Msg::Right); update(&mut model, Msg::Right); assert_eq!(model.config.selected_duration_idx, 0);
assert_eq!(model.config.time_limit, Duration::from_secs(15));
}
#[test]
fn left_cycles_duration_backward() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Left); assert_eq!(model.config.selected_duration_idx, 2);
assert_eq!(model.config.time_limit, Duration::from_secs(60));
}
#[test]
fn right_cycles_word_count_in_words_mode() {
let mut model = model_with_words(&["hello"]);
model.config.test_mode = TestMode::Words;
update(&mut model, Msg::Right); assert_eq!(model.config.selected_word_count_idx, 2);
assert_eq!(model.config.word_count, WORD_COUNT_OPTIONS[2]);
}
#[test]
fn left_cycles_word_count_in_words_mode() {
let mut model = model_with_words(&["hello"]);
model.config.test_mode = TestMode::Words;
update(&mut model, Msg::Left); assert_eq!(model.config.selected_word_count_idx, 0);
assert_eq!(model.config.word_count, WORD_COUNT_OPTIONS[0]);
}
#[test]
fn right_while_running_is_noop() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h')); assert_eq!(model.session.status, TestStatus::Running);
let idx_before = model.config.selected_duration_idx;
update(&mut model, Msg::Right);
assert_eq!(model.config.selected_duration_idx, idx_before);
}
#[test]
fn left_while_running_is_noop() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h'));
let idx_before = model.config.selected_duration_idx;
update(&mut model, Msg::Left);
assert_eq!(model.config.selected_duration_idx, idx_before);
}
#[test]
fn right_returns_generate_words_command() {
let mut model = model_with_words(&["hello"]);
let cmd = update(&mut model, Msg::Right);
assert!(matches!(cmd, Command::GenerateWords { .. }));
}
#[test]
fn tab_no_longer_cycles_duration() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Tab);
assert_eq!(model.config.selected_duration_idx, 0);
}
#[test]
fn shifttab_toggles_from_time_to_words() {
let mut model = model_with_words(&["hello"]);
assert_eq!(model.config.test_mode, TestMode::Time);
update(&mut model, Msg::ShiftTab);
assert_eq!(model.config.test_mode, TestMode::Words);
}
#[test]
fn shifttab_toggles_from_words_to_time() {
let mut model = model_with_words(&["hello"]);
model.config.test_mode = TestMode::Words;
update(&mut model, Msg::ShiftTab);
assert_eq!(model.config.test_mode, TestMode::Time);
}
#[test]
fn shifttab_while_running_is_noop() {
let mut model = model_with_words(&["hello"]);
update(&mut model, Msg::Char('h'));
assert_eq!(model.session.status, TestStatus::Running);
update(&mut model, Msg::ShiftTab);
assert_eq!(model.config.test_mode, TestMode::Time); }
#[test]
fn shifttab_returns_generate_words_command() {
let mut model = model_with_words(&["hello"]);
let cmd = update(&mut model, Msg::ShiftTab);
assert!(matches!(cmd, Command::GenerateWords { .. }));
}
#[test]
fn time_mode_last_char_appends_not_ends() {
let mut model = model_with_words(&["hi"]);
assert_eq!(model.config.test_mode, TestMode::Time);
update(&mut model, Msg::Char('h'));
let cmd = update(&mut model, Msg::Char('i')); assert!(matches!(cmd, Command::AppendWords { count: 1 }));
assert_eq!(model.session.status, TestStatus::Running);
assert_eq!(model.screen, Screen::Typing);
}
#[test]
fn time_mode_space_on_last_word_appends_not_ends() {
let mut model = model_with_words(&["hi"]);
assert_eq!(model.config.test_mode, TestMode::Time);
update(&mut model, Msg::Char('h'));
let cmd = update(&mut model, Msg::Space);
assert!(matches!(cmd, Command::AppendWords { count: 1 }));
assert_eq!(model.session.status, TestStatus::Running);
}
#[test]
fn time_mode_non_last_space_appends_word() {
let mut model = model_with_words(&["hi", "ok"]);
assert_eq!(model.config.test_mode, TestMode::Time);
update(&mut model, Msg::Char('h'));
let cmd = update(&mut model, Msg::Space); assert!(matches!(cmd, Command::AppendWords { count: 1 }));
assert_eq!(model.session.current_word, 1); }
#[test]
fn time_mode_current_word_stays_in_bounds_after_last_word_commit() {
let mut model = model_with_words(&["hi"]);
update(&mut model, Msg::Char('h'));
update(&mut model, Msg::Char('i')); assert!(model.session.current_word < model.session.words.len());
}
#[test]
fn words_mode_last_char_ends_test() {
let mut model = model_with_words(&["hi"]);
model.config.test_mode = TestMode::Words;
update(&mut model, Msg::Char('h'));
let cmd = update(&mut model, Msg::Char('i'));
assert_eq!(model.session.status, TestStatus::Done);
assert_eq!(model.screen, Screen::Done);
assert!(matches!(cmd, Command::SaveStats(_)));
}
#[test]
fn words_mode_space_on_last_word_ends_test() {
let mut model = model_with_words(&["hi"]);
model.config.test_mode = TestMode::Words;
update(&mut model, Msg::Char('h'));
let cmd = update(&mut model, Msg::Space);
assert_eq!(model.session.status, TestStatus::Done);
assert!(matches!(cmd, Command::SaveStats(_)));
}
#[test]
fn words_mode_tick_does_not_expire_test() {
let mut model = model_with_words(&["hello"]);
model.config.test_mode = TestMode::Words;
update(&mut model, Msg::Char('h')); update(&mut model, Msg::Tick(Duration::from_secs(999)));
assert_eq!(model.session.status, TestStatus::Running);
assert_eq!(model.screen, Screen::Typing);
}
#[test]
fn words_mode_tick_still_updates_elapsed() {
let mut model = model_with_words(&["hello"]);
model.config.test_mode = TestMode::Words;
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 words_mode_stats_payload_uses_elapsed() {
let mut model = model_with_words(&["hi"]);
model.config.test_mode = TestMode::Words;
model.session.elapsed = Duration::from_secs(7);
update(&mut model, Msg::Char('h'));
model.session.elapsed = Duration::from_secs(7);
let cmd = update(&mut model, Msg::Space); if let Command::SaveStats(payload) = cmd {
assert_eq!(payload.duration_secs, 7);
} else {
panic!("expected 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());
}
}
}
}
}