trane 0.33.0

An automated system for learning complex skills
Documentation
//! End-to-end tests for courses generated from course generator configurations.

use std::{collections::HashMap, path::Path};

use anyhow::Result;
use tempfile::TempDir;
use trane::{
    course_builder::{AssetBuilder, CourseBuilder},
    course_library::CourseLibrary,
    data::{
        CourseGenerator, CourseManifest, ExerciseType, LessonManifestBuilder, MasteryScore,
        UserPreferences,
        course_generator::{
            knowledge_base::KnowledgeBaseConfig,
            literacy::LiteracyConfig,
            transcription::{
                Instrument, TranscriptionAsset, TranscriptionConfig, TranscriptionPassages,
                TranscriptionPreferences,
            },
        },
    },
    test_utils::{TraneSimulation, assert_simulation_scores, init_simulation},
};

/// Creates a course manifest with the given generator configuration.
fn course_manifest(id: &str, generator: CourseGenerator) -> CourseManifest {
    CourseManifest {
        id: id.into(),
        name: id.into(),
        dependencies: vec![],
        encompassed: vec![],
        superseded: vec![],
        description: None,
        authors: None,
        metadata: None,
        course_material: None,
        course_instructions: None,
        generator_config: Some(generator),
    }
}

/// Creates a course builder with generator assets stored in the course directory.
fn course_builder(
    id: &str,
    generator: CourseGenerator,
    asset_builders: Vec<AssetBuilder>,
) -> CourseBuilder {
    CourseBuilder {
        directory_name: id.into(),
        course_manifest: course_manifest(id, generator),
        lesson_manifest_template: LessonManifestBuilder::default(),
        lesson_builders: vec![],
        asset_builders,
    }
}

/// Verifies that every exercise generated by a course can be reached by the scheduler.
fn assert_all_generated_exercises_visited(
    library_root: &Path,
    course_builder: CourseBuilder,
    user_preferences: UserPreferences,
    expected_exercises: usize,
) -> Result<()> {
    let mut trane = init_simulation(library_root, &[course_builder], Some(&user_preferences))?;
    let exercise_ids = trane.get_all_exercise_ids(None);
    assert_eq!(exercise_ids.len(), expected_exercises);

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

    assert_eq!(simulation.answer_history.len(), exercise_ids.len());
    for exercise_id in exercise_ids {
        assert!(
            simulation.answer_history.contains_key(&exercise_id),
            "exercise {exercise_id} should have been scheduled"
        );
        assert_simulation_scores(exercise_id, &trane, &simulation.answer_history)?;
    }
    Ok(())
}

/// Verifies that a knowledge-base course generates reachable exercises.
#[test]
fn knowledge_base_course_generator() -> Result<()> {
    let generator = CourseGenerator::KnowledgeBase(KnowledgeBaseConfig { inlined: true });
    let course_builder = course_builder(
        "knowledge_base_course",
        generator,
        vec![
            AssetBuilder {
                file_name: "lesson_0.lesson/exercise_0.front.md".into(),
                contents: "Front 0".into(),
            },
            AssetBuilder {
                file_name: "lesson_0.lesson/exercise_0.back.md".into(),
                contents: "Back 0".into(),
            },
            AssetBuilder {
                file_name: "lesson_1.lesson/exercise_1.front.md".into(),
                contents: "Front 1".into(),
            },
            AssetBuilder {
                file_name: "lesson_1.lesson/exercise_1.back.md".into(),
                contents: "Back 1".into(),
            },
            AssetBuilder {
                file_name: "lesson_1.lesson/lesson.dependencies.json".into(),
                contents: "[\"lesson_0\"]".into(),
            },
        ],
    );
    let temp_dir = TempDir::new()?;
    assert_all_generated_exercises_visited(
        temp_dir.path(),
        course_builder,
        UserPreferences::default(),
        2,
    )
}

/// Verifies that a literacy course generates reachable reading and dictation exercises.
#[test]
fn literacy_course_generator() -> Result<()> {
    let generator = CourseGenerator::Literacy(LiteracyConfig {
        generate_dictation: true,
        exercise_type: ExerciseType::Procedural,
    });
    let course_builder = course_builder(
        "literacy_course",
        generator,
        vec![
            AssetBuilder {
                file_name: "lesson_0.lesson/example_0.example.md".into(),
                contents: "Example 0".into(),
            },
            AssetBuilder {
                file_name: "lesson_1.lesson/example_1.example.md".into(),
                contents: "Example 1".into(),
            },
            AssetBuilder {
                file_name: "lesson_1.lesson/lesson.dependencies.json".into(),
                contents: "[\"lesson_0\"]".into(),
            },
        ],
    );
    let temp_dir = TempDir::new()?;
    assert_all_generated_exercises_visited(
        temp_dir.path(),
        course_builder,
        UserPreferences::default(),
        4,
    )
}

/// Verifies that a transcription course generates reachable singing and transcription exercises.
#[test]
fn transcription_course_generator() -> Result<()> {
    let passage = TranscriptionPassages {
        asset: TranscriptionAsset::Track {
            short_id: "track".into(),
            track_name: "Track".into(),
            artist_name: None,
            album_name: None,
            duration: None,
            external_link: None,
        },
        intervals: HashMap::from([(0, ("0:00".into(), "0:10".into()))]),
    };
    let generator = CourseGenerator::Transcription(TranscriptionConfig {
        transcription_dependencies: vec![],
        passage_directory: "passages".into(),
        inlined_passages: vec![],
        skip_singing_lessons: false,
        skip_advanced_lessons: false,
    });
    let course_builder = course_builder(
        "transcription_course",
        generator,
        vec![AssetBuilder {
            file_name: "passages/track.json".into(),
            contents: serde_json::to_string(&passage)?,
        }],
    );
    let user_preferences = UserPreferences {
        transcription: Some(TranscriptionPreferences {
            instruments: vec![Instrument {
                name: "Piano".into(),
                id: "piano".into(),
            }],
            ..Default::default()
        }),
        ..Default::default()
    };
    let temp_dir = TempDir::new()?;
    assert_all_generated_exercises_visited(temp_dir.path(), course_builder, user_preferences, 4)
}