#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadingLevel {
Children,
Standard,
Fast,
Unlimited,
}
impl ReadingLevel {
#[must_use]
pub fn cps_limit(self) -> Option<f64> {
match self {
Self::Children => Some(14.0),
Self::Standard => Some(17.0),
Self::Fast => Some(22.0),
Self::Unlimited => None,
}
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Children => "children",
Self::Standard => "standard",
Self::Fast => "fast",
Self::Unlimited => "unlimited",
}
}
}
#[derive(Debug, Clone)]
pub struct ReadingSpeedCheck {
pub cue_index: usize,
pub start_ms: i64,
pub end_ms: i64,
pub actual_cps: f64,
pub limit_cps: Option<f64>,
pub violation: bool,
}
impl ReadingSpeedCheck {
#[must_use]
pub fn is_too_fast(&self) -> bool {
self.violation
}
#[must_use]
pub fn excess_cps(&self) -> f64 {
match self.limit_cps {
Some(limit) if self.actual_cps > limit => self.actual_cps - limit,
_ => 0.0,
}
}
}
#[derive(Debug, Clone)]
pub struct SpeedCue {
pub index: usize,
pub start_ms: i64,
pub end_ms: i64,
pub text: String,
}
impl SpeedCue {
#[must_use]
pub fn new(index: usize, start_ms: i64, end_ms: i64, text: impl Into<String>) -> Self {
Self {
index,
start_ms,
end_ms,
text: text.into(),
}
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn duration_secs(&self) -> f64 {
let dur_ms = (self.end_ms - self.start_ms).max(0);
dur_ms as f64 / 1000.0
}
#[must_use]
pub fn char_count(&self) -> usize {
self.text.chars().filter(|c| !c.is_whitespace()).count()
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn cps(&self) -> f64 {
let dur = self.duration_secs();
if dur <= 0.0 {
return 0.0;
}
self.char_count() as f64 / dur
}
}
#[derive(Debug, Clone)]
pub struct ReadingSpeedAnalyzer {
pub level: ReadingLevel,
}
impl ReadingSpeedAnalyzer {
#[must_use]
pub fn new(level: ReadingLevel) -> Self {
Self { level }
}
#[must_use]
pub fn analyze(&self, cues: &[SpeedCue]) -> Vec<ReadingSpeedCheck> {
let limit = self.level.cps_limit();
cues.iter()
.map(|cue| {
let actual_cps = cue.cps();
let violation = limit.is_some_and(|l| actual_cps > l);
ReadingSpeedCheck {
cue_index: cue.index,
start_ms: cue.start_ms,
end_ms: cue.end_ms,
actual_cps,
limit_cps: limit,
violation,
}
})
.collect()
}
#[must_use]
pub fn violations(&self, cues: &[SpeedCue]) -> Vec<ReadingSpeedCheck> {
self.analyze(cues)
.into_iter()
.filter(|c| c.violation)
.collect()
}
}
#[derive(Debug, Clone)]
pub struct ReadingSpeedReport {
pub level: ReadingLevel,
pub total_cues: usize,
pub violation_count: usize,
pub max_cps: f64,
pub avg_cps: f64,
}
impl ReadingSpeedReport {
#[must_use]
pub fn from_checks(level: ReadingLevel, checks: &[ReadingSpeedCheck]) -> Self {
let total_cues = checks.len();
let violation_count = checks.iter().filter(|c| c.violation).count();
let max_cps = checks.iter().map(|c| c.actual_cps).fold(0.0f64, f64::max);
let avg_cps = if total_cues == 0 {
0.0
} else {
checks.iter().map(|c| c.actual_cps).sum::<f64>() / total_cues as f64
};
Self {
level,
total_cues,
violation_count,
max_cps,
avg_cps,
}
}
#[allow(clippy::cast_precision_loss)]
#[must_use]
pub fn compliance_pct(&self) -> f64 {
if self.total_cues == 0 {
return 100.0;
}
let compliant = self.total_cues.saturating_sub(self.violation_count);
compliant as f64 / self.total_cues as f64 * 100.0
}
#[must_use]
pub fn is_fully_compliant(&self) -> bool {
self.violation_count == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cue(index: usize, start_ms: i64, end_ms: i64, text: &str) -> SpeedCue {
SpeedCue::new(index, start_ms, end_ms, text)
}
#[test]
fn test_reading_level_cps_limit_children() {
assert_eq!(ReadingLevel::Children.cps_limit(), Some(14.0));
}
#[test]
fn test_reading_level_cps_limit_unlimited() {
assert_eq!(ReadingLevel::Unlimited.cps_limit(), None);
}
#[test]
fn test_reading_level_labels() {
assert_eq!(ReadingLevel::Standard.label(), "standard");
assert_eq!(ReadingLevel::Fast.label(), "fast");
}
#[test]
fn test_speed_cue_duration_secs() {
let cue = make_cue(0, 0, 2000, "hello");
assert!((cue.duration_secs() - 2.0).abs() < 0.001);
}
#[test]
fn test_speed_cue_char_count_excludes_spaces() {
let cue = make_cue(0, 0, 1000, "hello world");
assert_eq!(cue.char_count(), 10);
}
#[test]
fn test_speed_cue_cps() {
let cue = make_cue(0, 0, 2000, "hello world");
assert!((cue.cps() - 5.0).abs() < 0.01);
}
#[test]
fn test_speed_cue_zero_duration_cps() {
let cue = make_cue(0, 1000, 1000, "hello");
assert_eq!(cue.cps(), 0.0);
}
#[test]
fn test_reading_speed_check_is_too_fast() {
let check = ReadingSpeedCheck {
cue_index: 0,
start_ms: 0,
end_ms: 1000,
actual_cps: 20.0,
limit_cps: Some(17.0),
violation: true,
};
assert!(check.is_too_fast());
assert!((check.excess_cps() - 3.0).abs() < 0.01);
}
#[test]
fn test_reading_speed_check_not_too_fast() {
let check = ReadingSpeedCheck {
cue_index: 0,
start_ms: 0,
end_ms: 1000,
actual_cps: 10.0,
limit_cps: Some(17.0),
violation: false,
};
assert!(!check.is_too_fast());
assert_eq!(check.excess_cps(), 0.0);
}
#[test]
fn test_analyzer_no_violations_slow_text() {
let cues = vec![make_cue(0, 0, 5000, "Hello")];
let analyzer = ReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let violations = analyzer.violations(&cues);
assert!(violations.is_empty());
}
#[test]
fn test_analyzer_detects_violation() {
let cues = vec![make_cue(0, 0, 1000, "abcdefghijklmnopqrstuvwxyz")];
let analyzer = ReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let violations = analyzer.violations(&cues);
assert_eq!(violations.len(), 1);
}
#[test]
fn test_report_compliance_pct_full() {
let cues = vec![
make_cue(0, 0, 5000, "Short"),
make_cue(1, 5000, 10000, "Also short"),
];
let analyzer = ReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let checks = analyzer.analyze(&cues);
let report = ReadingSpeedReport::from_checks(ReadingLevel::Standard, &checks);
assert!((report.compliance_pct() - 100.0).abs() < 0.01);
assert!(report.is_fully_compliant());
}
#[test]
fn test_report_compliance_pct_partial() {
let cues = vec![
make_cue(0, 0, 1000, "abcdefghijklmnopqrstuvwxyz"), make_cue(1, 1000, 6000, "fine"), ];
let analyzer = ReadingSpeedAnalyzer::new(ReadingLevel::Standard);
let checks = analyzer.analyze(&cues);
let report = ReadingSpeedReport::from_checks(ReadingLevel::Standard, &checks);
assert!((report.compliance_pct() - 50.0).abs() < 0.01);
assert!(!report.is_fully_compliant());
}
#[test]
fn test_report_empty_cues() {
let report = ReadingSpeedReport::from_checks(ReadingLevel::Standard, &[]);
assert!((report.compliance_pct() - 100.0).abs() < 0.01);
assert_eq!(report.total_cues, 0);
}
}