139 lines
4.7 KiB
Markdown
139 lines
4.7 KiB
Markdown
# Task 9 — `get_progress` response (current_lesson + unlock rule)
|
|
|
|
> **Index:** [README](README.md). **Spec:** [design](../../specs/2026-04-29-quantum-tutor-design.md).
|
|
|
|
## Goal
|
|
|
|
Build a structured progress payload: per-module status (`locked | unlocked | in_progress | completed`), current module + current lesson, total exercises solved, percent complete. Spec §4 unlock rule: module `n+1` becomes `unlocked` once at least ⌈2/3⌉ of module `n`'s exercises are solved.
|
|
|
|
## Prerequisites
|
|
|
|
- Task 8 merged (`tutor_tools.rs` populated).
|
|
|
|
## Files
|
|
|
|
- Modify: `src/tools/tutor_tools.rs`
|
|
|
|
## Steps
|
|
|
|
- [ ] **Step 1: Append failing tests**
|
|
|
|
```rust
|
|
#[test]
|
|
fn get_progress_with_no_progress_returns_module_1_unlocked_others_locked() {
|
|
let resp = get_progress_response(&loader(), &UserProgress::default());
|
|
assert_eq!(resp["current_module"].as_u64().unwrap(), 1);
|
|
let modules = resp["modules"].as_array().unwrap();
|
|
assert_eq!(modules[0]["status"].as_str().unwrap(), "unlocked");
|
|
if modules.len() > 1 {
|
|
assert_eq!(modules[1]["status"].as_str().unwrap(), "locked");
|
|
}
|
|
assert_eq!(resp["total_exercises_solved"].as_u64().unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn get_progress_completed_module_marked_completed() {
|
|
let mut progress = UserProgress::default();
|
|
progress.mark_solved("1-1-a");
|
|
progress.mark_solved("1-1-b");
|
|
let resp = get_progress_response(&loader(), &progress);
|
|
assert_eq!(resp["modules"].as_array().unwrap()[0]["status"].as_str().unwrap(), "completed");
|
|
}
|
|
|
|
#[test]
|
|
fn get_progress_returns_current_lesson_for_active_module() {
|
|
let resp = get_progress_response(&loader(), &UserProgress::default());
|
|
assert_eq!(resp["current_lesson"].as_u64().unwrap(), 1);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Implementation**
|
|
|
|
```rust
|
|
pub fn get_progress_response(loader: &CurriculumLoader, progress: &UserProgress) -> Value {
|
|
let curriculum = match loader.curriculum() {
|
|
Ok(c) => c,
|
|
Err(e) => return json!({ "error": e.to_string() }),
|
|
};
|
|
|
|
let total_exercises: usize = loader.all_exercises().len();
|
|
let solved_count = progress.solved_exercises.len().min(total_exercises);
|
|
|
|
let mut prev_unlocks_next = true;
|
|
let modules: Vec<Value> = curriculum
|
|
.modules
|
|
.iter()
|
|
.map(|m| {
|
|
let total_in_module: usize = m.lessons.iter().map(|l| l.exercises.len()).sum();
|
|
let solved_in_module = m
|
|
.lessons
|
|
.iter()
|
|
.flat_map(|l| l.exercises.iter())
|
|
.filter(|e| progress.has_solved(&e.id))
|
|
.count();
|
|
let status = if !prev_unlocks_next {
|
|
"locked"
|
|
} else if total_in_module > 0 && solved_in_module == total_in_module {
|
|
"completed"
|
|
} else if solved_in_module > 0 {
|
|
"in_progress"
|
|
} else {
|
|
"unlocked"
|
|
};
|
|
// Spec §4: next module unlocks when ⌈2/3⌉ of current is solved.
|
|
let unlock_threshold = (total_in_module * 2 + 2) / 3;
|
|
prev_unlocks_next = solved_in_module >= unlock_threshold && total_in_module > 0;
|
|
json!({
|
|
"id": m.id,
|
|
"title": m.title,
|
|
"status": status,
|
|
"exercises_solved": solved_in_module,
|
|
"exercises_total": total_in_module,
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
let (current_module, current_lesson) = current_position(curriculum, progress);
|
|
let percent = if total_exercises == 0 { 0 } else { (solved_count * 100) / total_exercises };
|
|
|
|
json!({
|
|
"current_module": current_module,
|
|
"current_lesson": current_lesson,
|
|
"modules": modules,
|
|
"total_exercises_solved": solved_count,
|
|
"total_exercises": total_exercises,
|
|
"percent_complete": percent,
|
|
})
|
|
}
|
|
|
|
fn current_position(curriculum: &crate::tutor::Curriculum, progress: &UserProgress) -> (u32, u32) {
|
|
for module in &curriculum.modules {
|
|
for lesson in &module.lessons {
|
|
if lesson.exercises.iter().any(|e| !progress.has_solved(&e.id)) {
|
|
return (module.id, lesson.id);
|
|
}
|
|
}
|
|
}
|
|
let last = curriculum.modules.last();
|
|
(
|
|
last.map(|m| m.id).unwrap_or(1),
|
|
last.and_then(|m| m.lessons.last()).map(|l| l.id).unwrap_or(1),
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run tests**
|
|
|
|
```bash
|
|
cargo test tools::tutor_tools::tests 2>&1 | grep -E "test result|FAILED"
|
|
```
|
|
|
|
Expected: `test result: ok. 14 passed`.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/tools/tutor_tools.rs
|
|
git commit -m "feat: get_progress with current_lesson and module unlock rule (spec §4)"
|
|
```
|