trane 0.28.0

An automated system for learning complex skills
Documentation
//! End-to-end tests for the knowledge base course.

use std::path::Path;

use anyhow::Result;
use rand::{Rng, RngExt};
use tempfile::TempDir;
use trane::{
    Trane,
    course_builder::{
        AssetBuilder,
        knowledge_base_builder::{CourseBuilder, ExerciseBuilder, LessonBuilder},
    },
    course_library::CourseLibrary,
    data::{
        CourseGenerator, CourseManifest, ExerciseType, MasteryScore,
        course_generator::knowledge_base::{
            KnowledgeBaseConfig, KnowledgeBaseExercise, KnowledgeBaseLesson,
        },
    },
    test_utils::TraneSimulation,
};
use ustr::Ustr;

/// Generates a random number of dependencies for the lesson with the given index. All dependencies
/// will have a lower index to avoid cycles.
fn generate_lesson_dependencies(lesson_index: usize, rng: &mut impl Rng) -> Vec<Ustr> {
    let num_dependencies = rng.random_range(0..=lesson_index);
    if num_dependencies == 0 {
        return vec![];
    }

    let mut dependencies = Vec::with_capacity(num_dependencies);
    for _ in 0..num_dependencies.min(lesson_index) {
        let dependency_id = Ustr::from(&format!("lesson_{}", rng.random_range(0..lesson_index)));
        if dependencies.contains(&dependency_id) {
            continue;
        }
        dependencies.push(dependency_id);
    }
    dependencies
}

// Build a course with a given number of lessons and exercises per lesson. The dependencies are
// randomly generated.
fn knowledge_base_builder(
    directory_name: &str,
    course_manifest: CourseManifest,
    num_lessons: usize,
    num_exercises_per_lesson: usize,
) -> CourseBuilder {
    // Create the required number of lesson builders.
    let lessons = (0..num_lessons)
        .map(|lesson_index| {
            // Create the required number of exercise builders.
            let lesson_id = Ustr::from(&format!("lesson_{}", lesson_index));
            let exercises = (0..num_exercises_per_lesson)
                .map(|exercise_index| {
                    let front_path = format!("exercise_{}.front.md", exercise_index);
                    // Let even exercises have a back file and odds have none.
                    let back_path = if exercise_index % 2 == 0 {
                        Some(format!("exercise_{}.back.md", exercise_index))
                    } else {
                        None
                    };

                    // Create the asset and exercise builders.
                    let mut asset_builders = vec![AssetBuilder {
                        file_name: front_path.clone(),
                        contents: "Front".into(),
                    }];
                    if let Some(back_path) = &back_path {
                        asset_builders.push(AssetBuilder {
                            file_name: back_path.clone(),
                            contents: "Back".into(),
                        });
                    }
                    ExerciseBuilder {
                        exercise: KnowledgeBaseExercise {
                            short_id: format!("exercise_{}", exercise_index),
                            short_lesson_id: lesson_id,
                            course_id: course_manifest.id,
                            front_file: front_path,
                            back_file: back_path,
                            name: None,
                            description: None,
                            exercise_type: None,
                        },
                        asset_builders,
                    }
                })
                .collect();

            // Create the lesson builder.
            LessonBuilder {
                lesson: KnowledgeBaseLesson {
                    short_id: lesson_id,
                    course_id: course_manifest.id,
                    dependencies: generate_lesson_dependencies(lesson_index, &mut rand::rng()),
                    encompassed: vec![],
                    superseded: vec![],
                    name: None,
                    description: None,
                    metadata: None,
                    has_instructions: false,
                    has_material: false,
                    default_exercise_type: Some(ExerciseType::Declarative),
                },
                exercises,
                asset_builders: vec![],
            }
        })
        .collect();

    // Create the course builder.
    CourseBuilder {
        directory_name: directory_name.into(),
        lessons,
        assets: vec![],
        manifest: course_manifest,
    }
}

/// Creates the courses, initializes the Trane library, and returns a Trane instance.
fn init_knowledge_base_simulation(
    library_root: &Path,
    course_builders: &[CourseBuilder],
) -> Result<Trane> {
    // Build the courses.
    for builder in course_builders {
        let course_root = library_root.join(&builder.directory_name);
        builder.build(library_root)?;

        // Write a non-lesson directory to the the courses to verify it's skipped.
        std::fs::create_dir_all(course_root.join("not_a_lesson_directory"))?;
    }

    // Initialize the Trane library.
    let trane = Trane::new_local(library_root, library_root)?;
    Ok(trane)
}

// Verifies that generated knowledge base courses can be loaded and all their exercises can be
// reached.
#[test]
fn all_exercises_visited() -> Result<()> {
    let course1_builder = knowledge_base_builder(
        "course1",
        CourseManifest {
            id: Ustr::from("course1"),
            name: "Course 1".into(),
            description: None,
            dependencies: vec![],
            encompassed: vec![],
            superseded: vec![],
            authors: None,
            metadata: None,
            course_material: None,
            course_instructions: None,
            generator_config: Some(CourseGenerator::KnowledgeBase(KnowledgeBaseConfig {
                inlined: false,
            })),
        },
        10,
        5,
    );
    let course2_builder = knowledge_base_builder(
        "course2",
        CourseManifest {
            id: Ustr::from("course2"),
            name: "Course 2".into(),
            description: None,
            dependencies: vec!["course1".into()],
            encompassed: vec![],
            superseded: vec![],
            authors: None,
            metadata: None,
            course_material: None,
            course_instructions: None,
            generator_config: Some(CourseGenerator::KnowledgeBase(KnowledgeBaseConfig {
                inlined: true,
            })),
        },
        10,
        5,
    );

    // Initialize the Trane library.
    let temp_dir = TempDir::new()?;
    let mut trane =
        init_knowledge_base_simulation(temp_dir.path(), &vec![course1_builder, course2_builder])?;

    // Run the simulation.
    let exercise_ids = trane.get_all_exercise_ids(None);
    assert!(!exercise_ids.is_empty());
    let mut simulation = TraneSimulation::new(
        exercise_ids.len() * 10,
        Box::new(|_| Some(MasteryScore::Five)),
    );
    simulation.run_simulation(&mut trane, &vec![], &None)?;

    // Find all the exercises in the simulation history. All exercises should be visited.
    let visited_exercises = simulation.answer_history.keys().collect::<Vec<_>>();
    assert_eq!(visited_exercises.len(), exercise_ids.len());
    Ok(())
}