Passage drill improvements, stats page cleanup

This commit is contained in:
2026-02-18 00:14:37 +00:00
parent a61ed77ed6
commit 2d63cffb33
12 changed files with 1507 additions and 267 deletions

View File

@@ -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));
}
}

View File

@@ -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 });
}

View File

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