Initial import
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# 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)"
|
||||
```
|
||||
Reference in New Issue
Block a user