4.7 KiB
4.7 KiB
Task 6 — get_lesson response (first uncompleted lesson default)
Goal
Pure response function for get_lesson. When lesson_id is None, returns the first lesson in the module that has at least one unsolved exercise (spec §2.1). Each call surfaces the next pending exercise rather than always the first one. The function is pure (no I/O) and parameterized by the caller's UserProgress.
Prerequisites
- Task 2 merged (CurriculumLoader).
- Task 3 merged (UserProgress type).
Files
- Create:
src/tools/tutor_tools.rs - Modify:
src/tools/mod.rs(declare module)
Steps
- Step 1: Create
src/tools/tutor_tools.rswith implementation + tests
use serde_json::{json, Value};
use crate::progress::UserProgress;
use crate::tutor::CurriculumLoader;
pub fn get_lesson_response(
loader: &CurriculumLoader,
progress: &UserProgress,
module_id: u32,
lesson_id: Option<u32>,
) -> Value {
let module = match loader.get_module(module_id) {
None => return json!({ "error": format!("module {} not found", module_id) }),
Some(m) => m,
};
let resolved = lesson_id
.or_else(|| first_unsolved_lesson(module, progress))
.or_else(|| module.lessons.first().map(|l| l.id));
let resolved = match resolved {
Some(id) => id,
None => return json!({ "error": format!("module {} has no lessons", module_id) }),
};
let lesson = match loader.get_lesson(module_id, resolved) {
None => return json!({ "error": format!("module {} lesson {} not found", module_id, resolved) }),
Some(l) => l,
};
let pending_exercise = lesson
.exercises
.iter()
.find(|e| !progress.has_solved(&e.id));
if pending_exercise.is_none() && lesson.exercises.iter().all(|e| progress.has_solved(&e.id)) {
return json!({
"module_id": module_id,
"lesson_id": resolved,
"title": lesson.title,
"concept": lesson.concept,
"module_completed": all_lessons_completed(module, progress),
"message": "Toutes les exercices de cette leçon sont résolus.",
});
}
json!({
"module_id": module_id,
"lesson_id": resolved,
"title": lesson.title,
"concept": lesson.concept,
"example_circuit": lesson.example_circuit,
"what_to_observe": lesson.what_to_observe,
"exercise": pending_exercise.map(|e| json!({
"id": e.id,
"prompt": e.prompt,
"hint": e.hint,
})),
})
}
fn first_unsolved_lesson(module: &crate::tutor::Module, progress: &UserProgress) -> Option<u32> {
module
.lessons
.iter()
.find(|l| l.exercises.iter().any(|e| !progress.has_solved(&e.id)))
.map(|l| l.id)
}
fn all_lessons_completed(module: &crate::tutor::Module, progress: &UserProgress) -> bool {
module
.lessons
.iter()
.flat_map(|l| l.exercises.iter())
.all(|e| progress.has_solved(&e.id))
}
#[cfg(test)]
mod tests {
use super::*;
fn loader() -> CurriculumLoader { CurriculumLoader::default() }
#[test]
fn get_lesson_returns_first_pending_exercise_for_known_module() {
let resp = get_lesson_response(&loader(), &UserProgress::default(), 1, Some(1));
assert_eq!(resp["module_id"].as_u64().unwrap(), 1);
assert_eq!(resp["lesson_id"].as_u64().unwrap(), 1);
assert_eq!(resp["exercise"]["id"].as_str().unwrap(), "1-1-a");
}
#[test]
fn get_lesson_skips_solved_exercises_within_a_lesson() {
let mut progress = UserProgress::default();
progress.mark_solved("1-1-a");
let resp = get_lesson_response(&loader(), &progress, 1, Some(1));
assert_eq!(resp["exercise"]["id"].as_str().unwrap(), "1-1-b");
}
#[test]
fn get_lesson_default_resolves_to_first_lesson_with_pending_exercises() {
let resp = get_lesson_response(&loader(), &UserProgress::default(), 1, None);
assert_eq!(resp["lesson_id"].as_u64().unwrap(), 1);
}
#[test]
fn get_lesson_unknown_module_returns_error_payload() {
let resp = get_lesson_response(&loader(), &UserProgress::default(), 99, None);
assert!(resp.get("error").is_some());
}
}
- Step 2: Declare
tutor_toolsinsrc/tools/mod.rs
Add at the top:
pub mod tutor_tools;
- Step 3: Run tests
cargo test tools::tutor_tools::tests 2>&1 | grep -E "test result|FAILED"
Expected: test result: ok. 4 passed.
- Step 4: Commit
git add src/tools/tutor_tools.rs src/tools/mod.rs
git commit -m "feat: get_lesson response (first uncompleted lesson default)"