Passage drill improvements, stats page cleanup
This commit is contained in:
@@ -3,6 +3,12 @@ use std::time::Instant;
|
||||
|
||||
use crate::session::input::CharStatus;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SyntheticSpan {
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
pub struct DrillState {
|
||||
pub target: Vec<char>,
|
||||
pub input: Vec<CharStatus>,
|
||||
@@ -10,6 +16,7 @@ pub struct DrillState {
|
||||
pub started_at: Option<Instant>,
|
||||
pub finished_at: Option<Instant>,
|
||||
pub typo_flags: HashSet<usize>,
|
||||
pub synthetic_spans: Vec<SyntheticSpan>,
|
||||
}
|
||||
|
||||
impl DrillState {
|
||||
@@ -21,6 +28,7 @@ impl DrillState {
|
||||
started_at: None,
|
||||
finished_at: None,
|
||||
typo_flags: HashSet::new(),
|
||||
synthetic_spans: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,4 +166,72 @@ mod tests {
|
||||
assert_eq!(drill.typo_count(), 1);
|
||||
assert!(drill.typo_flags.contains(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_enter_skips_line_and_backspace_collapses() {
|
||||
let mut drill = DrillState::new("abcd\nef");
|
||||
input::process_char(&mut drill, 'a');
|
||||
assert_eq!(drill.cursor, 1);
|
||||
|
||||
// Wrong newline while expecting 'b' should skip to next line start.
|
||||
input::process_char(&mut drill, '\n');
|
||||
assert_eq!(drill.cursor, 5); // index after '\n'
|
||||
assert!(drill.typo_count() >= 4);
|
||||
for pos in 1..5 {
|
||||
assert!(drill.typo_flags.contains(&pos));
|
||||
}
|
||||
|
||||
// Backspacing at jump boundary collapses span to a single typo.
|
||||
input::process_backspace(&mut drill);
|
||||
assert_eq!(drill.cursor, 1);
|
||||
assert_eq!(drill.typo_count(), 1);
|
||||
assert!(drill.typo_flags.contains(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_tab_skips_tab_width_and_backspace_collapses() {
|
||||
let mut drill = DrillState::new("abcdef");
|
||||
input::process_char(&mut drill, 'a');
|
||||
assert_eq!(drill.cursor, 1);
|
||||
|
||||
// Tab jumps 4 chars (or to end of line).
|
||||
input::process_char(&mut drill, '\t');
|
||||
assert_eq!(drill.cursor, 5);
|
||||
for pos in 1..5 {
|
||||
assert!(drill.typo_flags.contains(&pos));
|
||||
}
|
||||
|
||||
input::process_backspace(&mut drill);
|
||||
assert_eq!(drill.cursor, 1);
|
||||
assert_eq!(drill.typo_count(), 1);
|
||||
assert!(drill.typo_flags.contains(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_tab_near_line_end_clamps_to_end_of_line() {
|
||||
let mut drill = DrillState::new("ab\ncd");
|
||||
input::process_char(&mut drill, 'a');
|
||||
input::process_char(&mut drill, 'b');
|
||||
// At newline position, a wrong tab should consume just this line remainder.
|
||||
input::process_char(&mut drill, '\t');
|
||||
assert_eq!(drill.cursor, 3);
|
||||
assert_eq!(drill.typo_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_synthetic_spans_collapse_to_single_error() {
|
||||
let mut drill = DrillState::new("abcd\nefgh");
|
||||
input::process_char(&mut drill, 'a');
|
||||
input::process_char(&mut drill, '\n');
|
||||
let after_newline = drill.cursor;
|
||||
input::process_char(&mut drill, '\t');
|
||||
assert!(drill.typo_count() > 1);
|
||||
|
||||
input::process_backspace(&mut drill); // collapse tab span
|
||||
assert_eq!(drill.cursor, after_newline);
|
||||
input::process_backspace(&mut drill); // collapse newline span
|
||||
assert_eq!(drill.cursor, 1);
|
||||
assert_eq!(drill.typo_count(), 1);
|
||||
assert!(drill.typo_flags.contains(&1));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::session::drill::DrillState;
|
||||
use crate::session::drill::{DrillState, SyntheticSpan};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CharStatus {
|
||||
@@ -38,11 +38,16 @@ pub fn process_char(drill: &mut DrillState, ch: char) -> Option<KeystrokeEvent>
|
||||
|
||||
if correct {
|
||||
drill.input.push(CharStatus::Correct);
|
||||
drill.cursor += 1;
|
||||
} else if ch == '\n' {
|
||||
apply_newline_span(drill, ch);
|
||||
} else if ch == '\t' {
|
||||
apply_tab_span(drill, ch);
|
||||
} else {
|
||||
drill.input.push(CharStatus::Incorrect(ch));
|
||||
drill.typo_flags.insert(drill.cursor);
|
||||
drill.cursor += 1;
|
||||
}
|
||||
drill.cursor += 1;
|
||||
|
||||
if drill.is_complete() {
|
||||
drill.finished_at = Some(Instant::now());
|
||||
@@ -52,8 +57,91 @@ pub fn process_char(drill: &mut DrillState, ch: char) -> Option<KeystrokeEvent>
|
||||
}
|
||||
|
||||
pub fn process_backspace(drill: &mut DrillState) {
|
||||
if drill.cursor > 0 {
|
||||
drill.cursor -= 1;
|
||||
drill.input.pop();
|
||||
if drill.cursor == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(span) = drill
|
||||
.synthetic_spans
|
||||
.last()
|
||||
.copied()
|
||||
.filter(|s| s.end == drill.cursor)
|
||||
{
|
||||
let span_len = span.end.saturating_sub(span.start);
|
||||
if span_len > 0 {
|
||||
let has_chained_prev = drill
|
||||
.synthetic_spans
|
||||
.iter()
|
||||
.rev()
|
||||
.nth(1)
|
||||
.is_some_and(|prev| prev.end == span.start);
|
||||
let new_len = drill.input.len().saturating_sub(span_len);
|
||||
drill.input.truncate(new_len);
|
||||
drill.cursor = span.start;
|
||||
for pos in span.start..span.end {
|
||||
drill.typo_flags.remove(&pos);
|
||||
}
|
||||
if !has_chained_prev {
|
||||
drill.typo_flags.insert(span.start);
|
||||
}
|
||||
drill.synthetic_spans.pop();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
drill.cursor -= 1;
|
||||
drill.input.pop();
|
||||
}
|
||||
|
||||
fn apply_newline_span(drill: &mut DrillState, typed: char) {
|
||||
let start = drill.cursor;
|
||||
let line_end = drill.target[start..]
|
||||
.iter()
|
||||
.position(|&c| c == '\n')
|
||||
.map(|offset| start + offset + 1)
|
||||
.unwrap_or(drill.target.len());
|
||||
let end = line_end.max(start + 1).min(drill.target.len());
|
||||
apply_synthetic_span(drill, start, end, typed, None);
|
||||
}
|
||||
|
||||
fn apply_tab_span(drill: &mut DrillState, typed: char) {
|
||||
let start = drill.cursor;
|
||||
let line_end = drill.target[start..]
|
||||
.iter()
|
||||
.position(|&c| c == '\n')
|
||||
.map(|offset| start + offset)
|
||||
.unwrap_or(drill.target.len());
|
||||
let mut end = (start + 4).min(line_end);
|
||||
if end <= start {
|
||||
end = (start + 1).min(drill.target.len());
|
||||
}
|
||||
let first_actual = drill.target.get(start).copied();
|
||||
apply_synthetic_span(drill, start, end, typed, first_actual);
|
||||
}
|
||||
|
||||
fn apply_synthetic_span(
|
||||
drill: &mut DrillState,
|
||||
start: usize,
|
||||
end: usize,
|
||||
typed: char,
|
||||
first_actual: Option<char>,
|
||||
) {
|
||||
if start >= end || start >= drill.target.len() {
|
||||
drill.input.push(CharStatus::Incorrect(typed));
|
||||
drill.typo_flags.insert(drill.cursor);
|
||||
drill.cursor += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
for idx in start..end {
|
||||
let actual = if idx == start {
|
||||
first_actual.unwrap_or(typed)
|
||||
} else {
|
||||
drill.target[idx]
|
||||
};
|
||||
drill.input.push(CharStatus::Incorrect(actual));
|
||||
drill.typo_flags.insert(idx);
|
||||
}
|
||||
drill.cursor = end;
|
||||
drill.synthetic_spans.push(SyntheticSpan { start, end });
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ pub struct DrillResult {
|
||||
pub drill_mode: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub ranked: bool,
|
||||
#[serde(default)]
|
||||
pub partial: bool,
|
||||
#[serde(default = "default_completion_percent")]
|
||||
pub completion_percent: f64,
|
||||
}
|
||||
|
||||
fn default_drill_mode() -> String {
|
||||
@@ -29,6 +33,10 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_completion_percent() -> f64 {
|
||||
100.0
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct KeyTime {
|
||||
pub key: char,
|
||||
@@ -42,6 +50,7 @@ impl DrillResult {
|
||||
events: &[KeystrokeEvent],
|
||||
drill_mode: &str,
|
||||
ranked: bool,
|
||||
partial: bool,
|
||||
) -> Self {
|
||||
let per_key_times: Vec<KeyTime> = events
|
||||
.windows(2)
|
||||
@@ -75,6 +84,8 @@ impl DrillResult {
|
||||
per_key_times,
|
||||
drill_mode: drill_mode.to_string(),
|
||||
ranked,
|
||||
partial,
|
||||
completion_percent: (drill.progress() * 100.0).clamp(0.0, 100.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user