trane 0.34.0

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

use std::collections::HashMap;

use anyhow::Result;
use tempfile::TempDir;
use trane::{
    Trane,
    course_builder::{AssetBuilder, CourseBuilder},
    course_library::CourseLibrary,
    data::{
        BasicAsset, CourseGenerator, CourseManifest, ExerciseAsset, 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(
    trane: &mut Trane,
    expected_exercises: usize,
) -> Result<()> {
    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(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 and path-backed assets.
#[test]
fn knowledge_base_course_generator_assets() -> Result<()> {
    // Generate a knowledge base course in disk with file assets.
    let generator = CourseGenerator::KnowledgeBase(KnowledgeBaseConfig { inlined: false });
    let mut course_builder = course_builder(
        "knowledge_base_course",
        generator,
        vec![
            AssetBuilder {
                file_name: "course.instructions.md".into(),
                contents: "Course instructions".into(),
            },
            AssetBuilder {
                file_name: "course.material.md".into(),
                contents: "Course material".into(),
            },
            AssetBuilder {
                file_name: "lesson_0.lesson/lesson.instructions.md".into(),
                contents: "Lesson instructions".into(),
            },
            AssetBuilder {
                file_name: "lesson_0.lesson/lesson.material.md".into(),
                contents: "Lesson material".into(),
            },
            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(),
            },
        ],
    );
    course_builder.course_manifest.course_instructions = Some(BasicAsset::MarkdownAsset {
        path: "course.instructions.md".into(),
    });
    course_builder.course_manifest.course_material = Some(BasicAsset::MarkdownAsset {
        path: "course.material.md".into(),
    });

    // Initialize the simulation and verify all exercises are visited.
    let temp_dir = TempDir::new()?;
    let mut trane = init_simulation(
        temp_dir.path(),
        &[course_builder],
        Some(&UserPreferences::default()),
    )?;
    assert_all_generated_exercises_visited(&mut trane, 2)?;
    let library_root = vfs::VfsPath::new(vfs::PhysicalFS::new(temp_dir.path()));
    let exercise_ids = trane.get_all_exercise_ids(None);
    assert_eq!(exercise_ids.len(), 2);

    // Verify the course manifest and its assets.
    let course_manifest = trane
        .get_course_manifest("knowledge_base_course".into())
        .unwrap();
    let assert_asset = |asset: &BasicAsset, expected_path: &str, expected_content: &str| {
        assert!(matches!(asset, BasicAsset::MarkdownAsset { .. }));
        if let BasicAsset::MarkdownAsset { path } = asset {
            assert_eq!(path, expected_path);
            assert_eq!(library_root.join(path)?.read_to_string()?, expected_content);
        }
        Ok::<(), anyhow::Error>(())
    };
    assert_asset(
        course_manifest.course_instructions.as_ref().unwrap(),
        "knowledge_base_course/course.instructions.md",
        "Course instructions",
    )?;
    assert_asset(
        course_manifest.course_material.as_ref().unwrap(),
        "knowledge_base_course/course.material.md",
        "Course material",
    )?;

    // Verify the lesson manifest and its assets.
    let lesson_manifest = trane
        .get_lesson_manifest("knowledge_base_course::lesson_0".into())
        .unwrap();
    assert_asset(
        lesson_manifest.lesson_instructions.as_ref().unwrap(),
        "knowledge_base_course/lesson_0.lesson/lesson.instructions.md",
        "Lesson instructions",
    )?;
    assert_asset(
        lesson_manifest.lesson_material.as_ref().unwrap(),
        "knowledge_base_course/lesson_0.lesson/lesson.material.md",
        "Lesson material",
    )?;

    // Verify all exercise assets are accessible based from the information in their manifests.
    let expected_assets = [
        (
            "knowledge_base_course::lesson_0::exercise_0",
            "Front 0",
            "Back 0",
        ),
        (
            "knowledge_base_course::lesson_1::exercise_1",
            "Front 1",
            "Back 1",
        ),
    ];
    for (exercise_id, expected_front, expected_back) in expected_assets {
        let manifest = trane.get_exercise_manifest(exercise_id.into()).unwrap();
        assert!(matches!(
            &manifest.exercise_asset,
            ExerciseAsset::FlashcardAsset { .. }
        ));
        if let ExerciseAsset::FlashcardAsset {
            front_path,
            back_path: Some(back_path),
        } = &manifest.exercise_asset
        {
            assert!(!front_path.starts_with('/'));
            assert!(!back_path.starts_with('/'));
            assert_eq!(
                library_root.join(front_path)?.read_to_string()?,
                expected_front
            );
            assert_eq!(
                library_root.join(back_path)?.read_to_string()?,
                expected_back
            );
        }
    }
    Ok(())
}

/// 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()?;
    let mut trane = init_simulation(
        temp_dir.path(),
        &[course_builder],
        Some(&UserPreferences::default()),
    )?;
    assert_all_generated_exercises_visited(&mut trane, 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()?;
    let mut trane = init_simulation(temp_dir.path(), &[course_builder], Some(&user_preferences))?;
    assert_all_generated_exercises_visited(&mut trane, 4)
}