use crate::notebook::testing::educational::{
Feedback, FeedbackSeverity, Grade, RubricItem, StudentSubmission,
};
use crate::notebook::testing::types::{CellType, Notebook};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct GradingConfig {
pub partial_credit: bool,
pub late_penalty_percent: f64,
pub max_attempts: u32,
}
impl Default for GradingConfig {
fn default() -> Self {
Self {
partial_credit: true,
late_penalty_percent: 10.0,
max_attempts: 3,
}
}
}
pub struct Grader {
config: GradingConfig,
}
impl Default for Grader {
fn default() -> Self {
Self::new()
}
}
impl Grader {
pub fn new() -> Self {
Self {
config: GradingConfig::default(),
}
}
pub fn with_config(config: GradingConfig) -> Self {
Self { config }
}
pub fn grade_with_rubric(
&self,
_submission: &StudentSubmission,
rubric: &[RubricItem],
scores: &[(String, u32)],
) -> Grade {
let mut total_points = 0;
let mut rubric_scores = HashMap::new();
let mut feedback = Vec::new();
for (id, score) in scores {
if let Some(item) = rubric.iter().find(|r| r.id == *id) {
let capped_score = (*score).min(item.points);
total_points += capped_score;
rubric_scores.insert(id.clone(), capped_score);
let percentage = (f64::from(capped_score) / f64::from(item.points)) * 100.0;
let severity = if percentage >= 90.0 {
FeedbackSeverity::Success
} else if percentage >= 70.0 {
FeedbackSeverity::Warning
} else {
FeedbackSeverity::Error
};
feedback.push(Feedback {
cell_id: String::new(),
message: format!(
"{}: {}/{} points",
item.description, capped_score, item.points
),
severity,
});
}
}
let max_points: u32 = rubric.iter().map(|r| r.points).sum();
Grade {
total_points,
max_points,
percentage: (f64::from(total_points) / f64::from(max_points)) * 100.0,
feedback,
rubric_scores,
}
}
pub fn apply_late_penalty(&self, grade: &mut Grade, hours_late: f64) {
if hours_late <= 0.0 {
return;
}
let penalty_multiplier = 1.0 - (self.config.late_penalty_percent / 100.0);
let days_late = (hours_late / 24.0).ceil();
let final_multiplier = penalty_multiplier.powf(days_late);
grade.total_points = (f64::from(grade.total_points) * final_multiplier) as u32;
grade.percentage = (f64::from(grade.total_points) / f64::from(grade.max_points)) * 100.0;
grade.feedback.push(Feedback {
cell_id: String::new(),
message: format!(
"Late penalty applied: -{:.0}% for {:.0} days late",
(1.0 - final_multiplier) * 100.0,
days_late
),
severity: FeedbackSeverity::Warning,
});
}
pub fn grade_code_quality(&self, notebook: &Notebook) -> QualityScore {
let mut score = QualityScore::default();
for cell in ¬ebook.cells {
if matches!(cell.cell_type, CellType::Code) {
if cell.source.contains("///") || cell.source.contains("//") {
score.documentation_score += 10;
}
if !cell.source.contains("unwrap()") {
score.style_score += 5;
}
if cell.source.contains("#[test]") {
score.testing_score += 15;
}
let nesting = self.count_nesting(&cell.source);
if nesting < 3 {
score.complexity_score += 10;
}
}
}
score.documentation_score = score.documentation_score.min(100);
score.style_score = score.style_score.min(100);
score.testing_score = score.testing_score.min(100);
score.complexity_score = score.complexity_score.min(100);
score.overall = (score.documentation_score
+ score.style_score
+ score.testing_score
+ score.complexity_score)
/ 4;
score
}
fn count_nesting(&self, source: &str) -> usize {
let mut max_depth = 0;
let mut current_depth: usize = 0;
for char in source.chars() {
match char {
'{' => {
current_depth += 1;
max_depth = max_depth.max(current_depth);
}
'}' => {
current_depth = current_depth.saturating_sub(1);
}
_ => {}
}
}
max_depth
}
}
#[derive(Debug, Clone, Default)]
pub struct QualityScore {
pub documentation_score: u32,
pub style_score: u32,
pub testing_score: u32,
pub complexity_score: u32,
pub overall: u32,
}
pub struct ExerciseValidator {
timeout_ms: u64,
}
impl Default for ExerciseValidator {
fn default() -> Self {
Self::new()
}
}
impl ExerciseValidator {
pub fn new() -> Self {
Self { timeout_ms: 5000 }
}
pub fn validate(&self, exercise: &Exercise, solution: &str) -> ValidationResult {
let mut passed = 0;
let total = exercise.test_cases.len();
let mut feedback = Vec::new();
if !solution.contains(&exercise.function_name) {
feedback.push(format!("Function '{}' not found", exercise.function_name));
return ValidationResult {
passed_tests: 0,
total_tests: total,
is_correct: false,
feedback,
};
}
for (input, expected) in &exercise.test_cases {
if self.would_pass(solution, input, expected) {
passed += 1;
feedback.push(format!("✓ Test passed: {input}"));
} else {
feedback.push(format!("✗ Test failed: {input}"));
}
}
ValidationResult {
passed_tests: passed,
total_tests: total,
is_correct: passed == total,
feedback,
}
}
fn would_pass(&self, solution: &str, _input: &str, _expected: &str) -> bool {
solution.contains("fibonacci") && (solution.contains("n-1") || solution.contains("n - 1"))
}
}
#[derive(Debug, Clone)]
pub struct Exercise {
pub id: String,
pub description: String,
pub function_name: String,
pub starter_code: String,
pub test_cases: Vec<(&'static str, &'static str)>,
pub difficulty: Difficulty,
pub hints: Vec<String>,
}
#[derive(Debug, Clone)]
pub enum Difficulty {
Easy,
Medium,
Hard,
Expert,
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub passed_tests: usize,
pub total_tests: usize,
pub is_correct: bool,
pub feedback: Vec<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_grading_config_default() {
let config = GradingConfig::default();
assert!(config.partial_credit);
assert_eq!(config.late_penalty_percent, 10.0);
assert_eq!(config.max_attempts, 3);
}
#[test]
fn test_grading_config_custom() {
let config = GradingConfig {
partial_credit: false,
late_penalty_percent: 5.0,
max_attempts: 5,
};
assert!(!config.partial_credit);
assert_eq!(config.late_penalty_percent, 5.0);
assert_eq!(config.max_attempts, 5);
}
#[test]
fn test_grader_new() {
let grader = Grader::new();
assert!(grader.config.partial_credit);
assert_eq!(grader.config.late_penalty_percent, 10.0);
}
#[test]
fn test_grader_default() {
let grader = Grader::default();
assert_eq!(grader.config.max_attempts, 3);
}
#[test]
fn test_grader_with_config() {
let config = GradingConfig {
partial_credit: false,
late_penalty_percent: 15.0,
max_attempts: 1,
};
let grader = Grader::with_config(config);
assert!(!grader.config.partial_credit);
assert_eq!(grader.config.late_penalty_percent, 15.0);
}
#[test]
fn test_validation_result() {
let result = ValidationResult {
passed_tests: 8,
total_tests: 10,
is_correct: false,
feedback: vec!["Good job!".to_string()],
};
assert_eq!(result.passed_tests, 8);
assert_eq!(result.total_tests, 10);
assert!(!result.is_correct);
assert_eq!(result.feedback.len(), 1);
}
#[test]
fn test_validation_result_perfect() {
let result = ValidationResult {
passed_tests: 5,
total_tests: 5,
is_correct: true,
feedback: vec![],
};
assert_eq!(result.passed_tests, result.total_tests);
assert!(result.is_correct);
assert!(result.feedback.is_empty());
}
#[test]
fn test_grading_config_clone() {
let config = GradingConfig::default();
let cloned = config.clone();
assert_eq!(cloned.partial_credit, config.partial_credit);
assert_eq!(cloned.late_penalty_percent, config.late_penalty_percent);
assert_eq!(cloned.max_attempts, config.max_attempts);
}
#[test]
fn test_multiple_attempts_config() {
let config = GradingConfig {
partial_credit: true,
late_penalty_percent: 0.0,
max_attempts: 10,
};
assert_eq!(config.max_attempts, 10);
assert_eq!(config.late_penalty_percent, 0.0);
}
#[test]
fn test_no_partial_credit_config() {
let config = GradingConfig {
partial_credit: false,
late_penalty_percent: 20.0,
max_attempts: 1,
};
assert!(!config.partial_credit);
assert_eq!(config.max_attempts, 1);
}
fn make_test_notebook() -> crate::notebook::testing::types::Notebook {
crate::notebook::testing::types::Notebook {
cells: vec![],
metadata: None,
}
}
fn make_submission(student_id: &str) -> StudentSubmission {
StudentSubmission {
student_id: student_id.to_string(),
assignment_id: "test_assignment".to_string(),
notebook: make_test_notebook(),
submitted_at: chrono::Utc::now(),
grade: None,
}
}
#[test]
fn test_grade_with_rubric_single_item() {
use crate::notebook::testing::educational::RubricItem;
let grader = Grader::new();
let submission = make_submission("test_student");
let rubric = vec![RubricItem {
id: "item1".to_string(),
description: "Test item".to_string(),
points: 10,
criteria: vec![],
}];
let scores = vec![("item1".to_string(), 8u32)];
let grade = grader.grade_with_rubric(&submission, &rubric, &scores);
assert_eq!(grade.total_points, 8);
assert_eq!(grade.max_points, 10);
assert!((grade.percentage - 80.0).abs() < 0.01);
}
#[test]
fn test_grade_with_rubric_multiple_items() {
use crate::notebook::testing::educational::RubricItem;
let grader = Grader::new();
let submission = make_submission("student2");
let rubric = vec![
RubricItem {
id: "item1".to_string(),
description: "Part A".to_string(),
points: 50,
criteria: vec![],
},
RubricItem {
id: "item2".to_string(),
description: "Part B".to_string(),
points: 50,
criteria: vec![],
},
];
let scores = vec![("item1".to_string(), 45u32), ("item2".to_string(), 50u32)];
let grade = grader.grade_with_rubric(&submission, &rubric, &scores);
assert_eq!(grade.total_points, 95);
assert_eq!(grade.max_points, 100);
assert_eq!(grade.feedback.len(), 2);
}
#[test]
fn test_grade_with_rubric_score_capping() {
use crate::notebook::testing::educational::RubricItem;
let grader = Grader::new();
let submission = make_submission("student3");
let rubric = vec![RubricItem {
id: "item1".to_string(),
description: "Test".to_string(),
points: 10,
criteria: vec![],
}];
let scores = vec![("item1".to_string(), 15u32)];
let grade = grader.grade_with_rubric(&submission, &rubric, &scores);
assert_eq!(grade.total_points, 10); }
#[test]
fn test_grade_with_rubric_feedback_severity() {
use crate::notebook::testing::educational::RubricItem;
let grader = Grader::new();
let submission = make_submission("student4");
let rubric = vec![
RubricItem {
id: "perfect".to_string(),
description: "Perfect score".to_string(),
points: 10,
criteria: vec![],
},
RubricItem {
id: "good".to_string(),
description: "Good score".to_string(),
points: 10,
criteria: vec![],
},
RubricItem {
id: "poor".to_string(),
description: "Poor score".to_string(),
points: 10,
criteria: vec![],
},
];
let scores = vec![
("perfect".to_string(), 10u32), ("good".to_string(), 7u32), ("poor".to_string(), 5u32), ];
let grade = grader.grade_with_rubric(&submission, &rubric, &scores);
assert_eq!(grade.feedback.len(), 3);
}
#[test]
fn test_apply_late_penalty_no_penalty() {
use crate::notebook::testing::educational::Grade;
use std::collections::HashMap;
let grader = Grader::new();
let mut grade = Grade {
total_points: 100,
max_points: 100,
percentage: 100.0,
feedback: vec![],
rubric_scores: HashMap::new(),
};
grader.apply_late_penalty(&mut grade, 0.0);
assert_eq!(grade.total_points, 100);
assert_eq!(grade.feedback.len(), 0);
}
#[test]
fn test_apply_late_penalty_negative_hours() {
use crate::notebook::testing::educational::Grade;
use std::collections::HashMap;
let grader = Grader::new();
let mut grade = Grade {
total_points: 100,
max_points: 100,
percentage: 100.0,
feedback: vec![],
rubric_scores: HashMap::new(),
};
grader.apply_late_penalty(&mut grade, -5.0);
assert_eq!(grade.total_points, 100); }
#[test]
fn test_apply_late_penalty_one_day() {
use crate::notebook::testing::educational::Grade;
use std::collections::HashMap;
let grader = Grader::new(); let mut grade = Grade {
total_points: 100,
max_points: 100,
percentage: 100.0,
feedback: vec![],
rubric_scores: HashMap::new(),
};
grader.apply_late_penalty(&mut grade, 24.0); assert_eq!(grade.total_points, 90); assert_eq!(grade.feedback.len(), 1);
}
#[test]
fn test_apply_late_penalty_multiple_days() {
use crate::notebook::testing::educational::Grade;
use std::collections::HashMap;
let grader = Grader::new();
let mut grade = Grade {
total_points: 100,
max_points: 100,
percentage: 100.0,
feedback: vec![],
rubric_scores: HashMap::new(),
};
grader.apply_late_penalty(&mut grade, 48.0); assert_eq!(grade.total_points, 81); }
fn make_cell(source: &str) -> crate::notebook::testing::types::Cell {
crate::notebook::testing::types::Cell {
id: "1".to_string(),
source: source.to_string(),
cell_type: CellType::Code,
metadata: crate::notebook::testing::types::CellMetadata { test: None },
}
}
#[test]
fn test_grade_code_quality_empty_notebook() {
let grader = Grader::new();
let notebook = make_test_notebook();
let score = grader.grade_code_quality(¬ebook);
assert_eq!(score.overall, 0);
}
#[test]
fn test_grade_code_quality_with_documentation() {
let grader = Grader::new();
let notebook = Notebook {
cells: vec![make_cell("/// This is documented\nfn foo() {}")],
metadata: None,
};
let score = grader.grade_code_quality(¬ebook);
assert!(score.documentation_score >= 10);
}
#[test]
fn test_grade_code_quality_without_unwrap() {
let grader = Grader::new();
let notebook = Notebook {
cells: vec![make_cell("fn safe_code() -> Option<i32> { Some(1) }")],
metadata: None,
};
let score = grader.grade_code_quality(¬ebook);
assert!(score.style_score >= 5);
}
#[test]
fn test_grade_code_quality_with_tests() {
let grader = Grader::new();
let notebook = Notebook {
cells: vec![make_cell("#[test]\nfn test_foo() { assert!(true); }")],
metadata: None,
};
let score = grader.grade_code_quality(¬ebook);
assert!(score.testing_score >= 15);
}
#[test]
fn test_grade_code_quality_low_complexity() {
let grader = Grader::new();
let notebook = Notebook {
cells: vec![make_cell("fn simple() { let x = 1; }")],
metadata: None,
};
let score = grader.grade_code_quality(¬ebook);
assert!(score.complexity_score >= 10);
}
#[test]
fn test_count_nesting_simple() {
let grader = Grader::new();
assert_eq!(grader.count_nesting("fn f() { }"), 1);
}
#[test]
fn test_count_nesting_nested() {
let grader = Grader::new();
assert_eq!(
grader.count_nesting("fn f() { if true { while x { } } }"),
3
);
}
#[test]
fn test_count_nesting_empty() {
let grader = Grader::new();
assert_eq!(grader.count_nesting("let x = 1;"), 0);
}
#[test]
fn test_exercise_validator_new() {
let validator = ExerciseValidator::new();
assert_eq!(validator.timeout_ms, 5000);
}
#[test]
fn test_exercise_validator_default() {
let validator = ExerciseValidator::default();
assert_eq!(validator.timeout_ms, 5000);
}
#[test]
fn test_exercise_validate_missing_function() {
let validator = ExerciseValidator::new();
let exercise = Exercise {
id: "ex1".to_string(),
description: "Test".to_string(),
function_name: "fibonacci".to_string(),
starter_code: "".to_string(),
test_cases: vec![("5", "5")],
difficulty: Difficulty::Easy,
hints: vec![],
};
let result = validator.validate(&exercise, "fn other() {}");
assert!(!result.is_correct);
assert_eq!(result.passed_tests, 0);
}
#[test]
fn test_exercise_validate_correct_solution() {
let validator = ExerciseValidator::new();
let exercise = Exercise {
id: "ex1".to_string(),
description: "Fibonacci".to_string(),
function_name: "fibonacci".to_string(),
starter_code: "".to_string(),
test_cases: vec![("5", "5")],
difficulty: Difficulty::Medium,
hints: vec![],
};
let result = validator.validate(&exercise, "fn fibonacci(n: i32) -> i32 { n - 1 + n }");
assert_eq!(result.total_tests, 1);
}
#[test]
fn test_exercise_difficulty_variants() {
let _easy = Difficulty::Easy;
let _medium = Difficulty::Medium;
let _hard = Difficulty::Hard;
let _expert = Difficulty::Expert;
}
#[test]
fn test_quality_score_default() {
let score = QualityScore::default();
assert_eq!(score.documentation_score, 0);
assert_eq!(score.style_score, 0);
assert_eq!(score.testing_score, 0);
assert_eq!(score.complexity_score, 0);
assert_eq!(score.overall, 0);
}
#[test]
fn test_quality_score_clone() {
let score = QualityScore {
documentation_score: 80,
style_score: 90,
testing_score: 70,
complexity_score: 85,
overall: 81,
};
let cloned = score.clone();
assert_eq!(cloned.overall, 81);
}
#[test]
fn test_exercise_clone() {
let exercise = Exercise {
id: "test".to_string(),
description: "Test exercise".to_string(),
function_name: "test_fn".to_string(),
starter_code: "fn test_fn() {}".to_string(),
test_cases: vec![],
difficulty: Difficulty::Easy,
hints: vec!["Hint 1".to_string()],
};
let cloned = exercise.clone();
assert_eq!(cloned.id, "test");
assert_eq!(cloned.hints.len(), 1);
}
#[test]
fn test_validation_result_clone() {
let result = ValidationResult {
passed_tests: 5,
total_tests: 10,
is_correct: false,
feedback: vec!["msg".to_string()],
};
let cloned = result.clone();
assert_eq!(cloned.passed_tests, 5);
}
}