From 0d1f7b30be3f2954a2b8315df1a143ccc99e5cd3 Mon Sep 17 00:00:00 2001 From: Martin Martinez Rivera Date: Thu, 1 Aug 2024 20:32:37 -0700 Subject: [PATCH] Basic literacy generated course (#314) --- bindings/CourseGenerator.ts | 3 +- bindings/ExerciseAsset.ts | 15 +- bindings/LiteracyConfig.ts | 31 ++ bindings/LiteracyLesson.ts | 6 + src/data.rs | 119 ++--- src/data/course_generator.rs | 1 + src/data/course_generator/knowledge_base.rs | 7 +- src/data/course_generator/literacy.rs | 464 ++++++++++++++++++ .../transcription/constants.rs | 3 +- 9 files changed, 560 insertions(+), 89 deletions(-) create mode 100644 bindings/LiteracyConfig.ts create mode 100644 bindings/LiteracyLesson.ts create mode 100644 src/data/course_generator/literacy.rs diff --git a/bindings/CourseGenerator.ts b/bindings/CourseGenerator.ts index 6436aa0..2eb9a21 100644 --- a/bindings/CourseGenerator.ts +++ b/bindings/CourseGenerator.ts @@ -1,9 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { KnowledgeBaseConfig } from "./KnowledgeBaseConfig"; +import type { LiteracyConfig } from "./LiteracyConfig"; import type { MusicPieceConfig } from "./MusicPieceConfig"; import type { TranscriptionConfig } from "./TranscriptionConfig"; /** * A configuration used for generating special types of courses on the fly. */ -export type CourseGenerator = { "KnowledgeBase": KnowledgeBaseConfig } | { "MusicPiece": MusicPieceConfig } | { "Transcription": TranscriptionConfig }; +export type CourseGenerator = { "KnowledgeBase": KnowledgeBaseConfig } | { "Literacy": LiteracyConfig } | { "MusicPiece": MusicPieceConfig } | { "Transcription": TranscriptionConfig }; diff --git a/bindings/ExerciseAsset.ts b/bindings/ExerciseAsset.ts index c99a2db..720dc9d 100644 --- a/bindings/ExerciseAsset.ts +++ b/bindings/ExerciseAsset.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { BasicAsset } from "./BasicAsset"; +import type { LiteracyLesson } from "./LiteracyLesson"; import type { TranscriptionLink } from "./TranscriptionLink"; /** @@ -16,7 +17,19 @@ front_path: string, * open-ended, or it is referring to an external resource which contains the exercise and * possibly the answer. */ -back_path: string | null, } } | { "SoundSliceAsset": { +back_path: string | null, } } | { "LiteracyAsset": { +/** + * The type of the lesson. + */ +lesson_type: LiteracyLesson, +/** + * The examples to use in the lesson's exercise. + */ +examples: Array, +/** + * The exceptions to the examples to use in the lesson's exercise. + */ +exceptions: Array, } } | { "SoundSliceAsset": { /** * The link to the SoundSlice asset. */ diff --git a/bindings/LiteracyConfig.ts b/bindings/LiteracyConfig.ts new file mode 100644 index 0000000..89508f5 --- /dev/null +++ b/bindings/LiteracyConfig.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The configuration to create a course that teaches literacy based on the provided material. + * Material can be of two types. + * + * 1. Examples. For example, they can be words that share the same spelling and pronunciation (e.g. + * "cat", "bat", "hat"), sentences that share similar words, or sentences from the same book or + * article (for more advanced courses). + * 2. Exceptions. For example, they can be words that share the same spelling but have different + * pronunciations (e.g. "cow" and "crow"). + * + * All examples and exceptions accept Markdown syntax. Examples and exceptions can be declared in + * the configuration or in separate files in the course's directory. Files that end with the + * extensions ".examples.md" and ".exceptions.md" will be considered as examples and exceptions, + * respectively. + */ +export type LiteracyConfig = { +/** + * Inlined examples to use in the course. + */ +inlined_examples: Array, +/** + * Inlined exceptions to use in the course. + */ +inlined_exceptions: Array, +/** + * Indicates whether to generate an optional lesson that asks the student to write the material + * based on the tutor's dictation. + */ +generate_dictation: boolean, }; diff --git a/bindings/LiteracyLesson.ts b/bindings/LiteracyLesson.ts new file mode 100644 index 0000000..805697d --- /dev/null +++ b/bindings/LiteracyLesson.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * The types of literacy lessons that can be generated. + */ +export type LiteracyLesson = "Reading" | "Dictation"; diff --git a/src/data.rs b/src/data.rs index 8699ad9..f7a7c1c 100644 --- a/src/data.rs +++ b/src/data.rs @@ -13,8 +13,9 @@ use std::{collections::BTreeMap, path::Path}; use ts_rs::TS; use ustr::Ustr; -use self::course_generator::{ +use crate::data::course_generator::{ knowledge_base::KnowledgeBaseConfig, + literacy::{LiteracyConfig, LiteracyLesson}, music_piece::MusicPieceConfig, transcription::{TranscriptionConfig, TranscriptionLink, TranscriptionPreferences}, }; @@ -216,13 +217,15 @@ pub enum BasicAsset { impl NormalizePaths for BasicAsset { fn normalize_paths(&self, working_dir: &Path) -> Result { match &self { + // grcov-excl-start: This is a no-op for these variants. + BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => { + Ok(self.clone()) + } + // grcov-excl-stop BasicAsset::MarkdownAsset { path } => { let abs_path = normalize_path(working_dir, path)?; Ok(BasicAsset::MarkdownAsset { path: abs_path }) } - BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => { - Ok(self.clone()) - } } } } @@ -230,11 +233,11 @@ impl NormalizePaths for BasicAsset { impl VerifyPaths for BasicAsset { fn verify_paths(&self, working_dir: &Path) -> Result { match &self { + BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => Ok(true), // grcov-excl-line BasicAsset::MarkdownAsset { path } => { let abs_path = working_dir.join(Path::new(path)); Ok(abs_path.exists()) } - BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => Ok(true), } } } @@ -249,6 +252,9 @@ pub enum CourseGenerator { /// and for future extensibility. KnowledgeBase(KnowledgeBaseConfig), + /// The configuration for generating a literacy course. + Literacy(LiteracyConfig), + /// The configuration for generating a music piece course. MusicPiece(MusicPieceConfig), @@ -258,6 +264,7 @@ pub enum CourseGenerator { //>@course-generator /// A struct holding the results from running a course generator. +#[derive(Debug, PartialEq)] pub struct GeneratedCourse { /// The lessons and exercise manifests generated for the course. pub lessons: Vec<(LessonManifest, Vec)>, @@ -291,6 +298,9 @@ impl GenerateManifests for CourseGenerator { CourseGenerator::KnowledgeBase(config) => { config.generate_manifests(course_root, course_manifest, preferences) } + CourseGenerator::Literacy(config) => { + config.generate_manifests(course_root, course_manifest, preferences) + } CourseGenerator::MusicPiece(config) => { config.generate_manifests(course_root, course_manifest, preferences) } @@ -564,6 +574,20 @@ pub enum ExerciseAsset { back_path: Option, }, + /// An asset representing a literacy exercise. + LiteracyAsset { + /// The type of the lesson. + lesson_type: LiteracyLesson, + + /// The examples to use in the lesson's exercise. + #[serde(default)] + examples: Vec, + + /// The exceptions to the examples to use in the lesson's exercise. + #[serde(default)] + exceptions: Vec, + }, + /// An asset which stores a link to a SoundSlice. SoundSliceAsset { /// The link to the SoundSlice asset. @@ -617,6 +641,11 @@ impl NormalizePaths for ExerciseAsset { back_path: abs_back_path, }) } + // grcov-excl-start: This is a no-op for these variants. + ExerciseAsset::LiteracyAsset { .. } | ExerciseAsset::TranscriptionAsset { .. } => { + Ok(self.clone()) + } + // grcov-excl-stop ExerciseAsset::SoundSliceAsset { link, description, @@ -632,7 +661,6 @@ impl NormalizePaths for ExerciseAsset { }) } }, - ExerciseAsset::TranscriptionAsset { .. } => Ok(self.clone()), } } } @@ -655,6 +683,11 @@ impl VerifyPaths for ExerciseAsset { Ok(front_abs_path.exists()) } } + // grcov-excl-start: This is a no-op for these variants. + ExerciseAsset::LiteracyAsset { .. } | ExerciseAsset::TranscriptionAsset { .. } => { + Ok(true) + } + // grcov-excl-stop ExerciseAsset::SoundSliceAsset { backup, .. } => match backup { None => Ok(true), Some(path) => { @@ -663,7 +696,6 @@ impl VerifyPaths for ExerciseAsset { Ok(abs_path.exists()) } }, - ExerciseAsset::TranscriptionAsset { .. } => Ok(true), } } } @@ -1140,57 +1172,6 @@ mod test { Ok(()) } - /// Verifies the `NormalizePaths` trait works for an inlined asset. - #[test] - fn normalize_inlined_assets() -> Result<()> { - let inlined_asset = BasicAsset::InlinedAsset { - content: "Test".to_string(), - }; - inlined_asset.normalize_paths(Path::new("./"))?; - - let inlined_asset = BasicAsset::InlinedUniqueAsset { - content: Ustr::from("Test"), - }; - inlined_asset.normalize_paths(Path::new("./"))?; - Ok(()) - } - - /// Verifies the `VerifyPaths` trait works for an inlined asset. - #[test] - fn verify_inlined_assets() -> Result<()> { - let inlined_asset = BasicAsset::InlinedAsset { - content: "Test".to_string(), - }; - assert!(inlined_asset.verify_paths(Path::new("./"))?); - - let inlined_asset = BasicAsset::InlinedUniqueAsset { - content: Ustr::from("Test"), - }; - assert!(inlined_asset.verify_paths(Path::new("./"))?); - Ok(()) - } - - /// Verifies the `NormalizePaths` trait works for a transcription asset. - #[test] - fn transcription_normalize_paths() { - let asset = ExerciseAsset::TranscriptionAsset { - content: "content".into(), - external_link: None, - }; - assert!(asset.normalize_paths(Path::new("./")).is_ok()); - } - - /// Verifies the `VerifyPaths` trait works for a transcription asset. - #[test] - fn transcription_verify_paths() -> Result<()> { - let asset = ExerciseAsset::TranscriptionAsset { - content: "content".into(), - external_link: None, - }; - assert!(asset.verify_paths(Path::new("./"))?); - Ok(()) - } - /// Verifies the `VerifyPaths` trait works for a flashcard asset. #[test] fn verify_flashcard_assets() -> Result<()> { @@ -1251,28 +1232,6 @@ mod test { Ok(()) } - /// Verifies the `VerifyPaths` trait works for a basic exercise asset. - #[test] - fn exercise_basic_asset_verify_paths() -> Result<()> { - let temp_dir = tempfile::tempdir()?; - let basic_asset = ExerciseAsset::BasicAsset(BasicAsset::InlinedAsset { - content: "my content".to_string(), - }); - assert!(basic_asset.verify_paths(temp_dir.path())?); - Ok(()) - } - - /// Verifies the `NormalizePaths` trait works for a basic exercise asset. - #[test] - fn exercise_basic_asset_normalize_paths() -> Result<()> { - let temp_dir = tempfile::tempdir()?; - let basic_asset = ExerciseAsset::BasicAsset(BasicAsset::InlinedAsset { - content: "my content".to_string(), - }); - basic_asset.normalize_paths(temp_dir.path())?; - Ok(()) - } - /// Verifies the default scheduler options are valid. #[test] fn valid_default_scheduler_options() { diff --git a/src/data/course_generator.rs b/src/data/course_generator.rs index f35c6aa..7f2836c 100644 --- a/src/data/course_generator.rs +++ b/src/data/course_generator.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use ts_rs::TS; pub mod knowledge_base; +pub mod literacy; pub mod music_piece; pub mod transcription; diff --git a/src/data/course_generator/knowledge_base.rs b/src/data/course_generator/knowledge_base.rs index 78fceed..e3abfd0 100644 --- a/src/data/course_generator/knowledge_base.rs +++ b/src/data/course_generator/knowledge_base.rs @@ -562,12 +562,9 @@ impl GenerateManifests for KnowledgeBaseConfig { course_manifest: &CourseManifest, _preferences: &UserPreferences, ) -> Result { - // Store the lessons and their exercises in a map of short lesson ID to a tuple of the - // lesson and its exercises. + // Create the lessons by iterating through all the directories in the course root, + // processing only those whose name fits the pattern `.lesson`. let mut lessons = UstrMap::default(); - - // Iterate through all the directories in the course root, processing only those whose name - // fits the pattern `.lesson`. for entry in read_dir(course_root)? { // Ignore the entry if it's not a directory. let entry = entry?; diff --git a/src/data/course_generator/literacy.rs b/src/data/course_generator/literacy.rs new file mode 100644 index 0000000..b5c9721 --- /dev/null +++ b/src/data/course_generator/literacy.rs @@ -0,0 +1,464 @@ +//! Defines a special course to teach literacy skills. +//! +//! The student is presented with examples and exceptions that match a certain spelling rule or type +//! of reading material. They are asked to read the example and exceptions and are scored based on +//! how many they get right. Optionally, a dictation lesson can be generated where the student is +//! asked to write the examples and exceptions based on the tutor's dictation. + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, fs, path::Path}; +use ts_rs::TS; + +use crate::data::{ + CourseGenerator, CourseManifest, ExerciseAsset, ExerciseManifest, ExerciseType, + GenerateManifests, GeneratedCourse, LessonManifest, UserPreferences, +}; + +/// The metadata key indicating this is a literacy course. Its value should be set to "true". +pub const COURSE_METADATA: &str = "literacy_course"; + +/// The metadata indicating the type of literacy lesson. +pub const LESSON_METADATA: &str = "literacy_lesson"; + +/// The extension of files containing examples. +pub const EXAMPLE_SUFFIX: &str = ".example.md"; + +/// The extension of files containing exceptions. +pub const EXCEPTION_SUFFIX: &str = ".exception.md"; + +// grcov-excl-start: not meeting coverage requirements for some reason. +/// The types of literacy lessons that can be generated. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)] +#[ts(export)] +pub enum LiteracyLesson { + /// A lesson that takes examples and exceptions and asks the student to read them. + Reading, + + /// A lesson that takes examples and exceptions and asks the student to write them based on the + /// tutor's dictation. + Dictation, +} +// grcov-excl-stop + +/// The configuration to create a course that teaches literacy based on the provided material. +/// Material can be of two types. +/// +/// 1. Examples. For example, they can be words that share the same spelling and pronunciation (e.g. +/// "cat", "bat", "hat"), sentences that share similar words, or sentences from the same book or +/// article (for more advanced courses). +/// 2. Exceptions. For example, they can be words that share the same spelling but have different +/// pronunciations (e.g. "cow" and "crow"). +/// +/// All examples and exceptions accept Markdown syntax. Examples and exceptions can be declared in +/// the configuration or in separate files in the course's directory. Files that end with the +/// extensions ".examples.md" and ".exceptions.md" will be considered as examples and exceptions, +/// respectively. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)] +#[ts(export)] +pub struct LiteracyConfig { + /// Inlined examples to use in the course. + #[serde(default)] + inlined_examples: Vec, + + /// Inlined exceptions to use in the course. + #[serde(default)] + inlined_exceptions: Vec, + + /// Indicates whether to generate an optional lesson that asks the student to write the material + /// based on the tutor's dictation. + #[serde(default)] + pub generate_dictation: bool, +} + +impl LiteracyConfig { + fn generate_reading_lesson( + course_manifest: &CourseManifest, + examples: &[String], + exceptions: &[String], + ) -> (LessonManifest, Vec) { + // Create the lesson manifest. + let lesson_manifest = LessonManifest { + id: format!("{}::reading", course_manifest.id).into(), + dependencies: vec![], + superseded: vec![], + course_id: course_manifest.id, + name: format!("{} - Reading", course_manifest.name), + description: None, + metadata: Some(BTreeMap::from([( + LESSON_METADATA.to_string(), + vec!["reading".to_string()], + )])), + lesson_material: None, + lesson_instructions: None, + }; + + // Create the exercise manifest. + let exercise_manifest = ExerciseManifest { + id: format!("{}::reading::exercise", course_manifest.id).into(), + lesson_id: lesson_manifest.id, + course_id: course_manifest.id, + name: format!("{} - Reading", course_manifest.name), + description: None, + exercise_type: ExerciseType::Procedural, + exercise_asset: ExerciseAsset::LiteracyAsset { + lesson_type: LiteracyLesson::Reading, + examples: examples.to_vec(), + exceptions: exceptions.to_vec(), + }, + }; + (lesson_manifest, vec![exercise_manifest]) + } + + fn generate_dictation_lesson( + course_manifest: &CourseManifest, + examples: &[String], + exceptions: &[String], + ) -> Option<(LessonManifest, Vec)> { + // Exit early if the dictation lesson should not be generated. + let generate_dictation = + if let Some(CourseGenerator::Literacy(config)) = &course_manifest.generator_config { + config.generate_dictation + } else { + false // grcov-excl-line + }; + if !generate_dictation { + return None; + } + + // Create the lesson manifest. + let lesson_manifest = LessonManifest { + id: format!("{}::dictation", course_manifest.id).into(), + dependencies: vec![format!("{}::reading", course_manifest.id).into()], + superseded: vec![], + course_id: course_manifest.id, + name: format!("{} - Dictation", course_manifest.name), + description: None, + metadata: Some(BTreeMap::from([( + LESSON_METADATA.to_string(), + vec!["dictation".to_string()], + )])), + lesson_material: None, + lesson_instructions: None, + }; + + // Create the exercise manifest. + let exercise_manifest = ExerciseManifest { + id: format!("{}::dictation::exercise", course_manifest.id).into(), + lesson_id: lesson_manifest.id, + course_id: course_manifest.id, + name: format!("{} - Dictation", course_manifest.name), + description: None, + exercise_type: ExerciseType::Procedural, + exercise_asset: ExerciseAsset::LiteracyAsset { + lesson_type: LiteracyLesson::Dictation, + examples: examples.to_vec(), + exceptions: exceptions.to_vec(), + }, + }; + Some((lesson_manifest, vec![exercise_manifest])) + } + + /// Generates the reading lesson and the optional dictation lesson. + fn generate_lessons( + course_manifest: &CourseManifest, + examples: &[String], + exceptions: &[String], + ) -> Vec<(LessonManifest, Vec)> { + if let Some(lesson) = Self::generate_dictation_lesson(course_manifest, examples, exceptions) + { + vec![ + Self::generate_reading_lesson(course_manifest, examples, exceptions), + lesson, + ] + } else { + vec![Self::generate_reading_lesson( + course_manifest, + examples, + exceptions, + )] + } + } +} + +impl GenerateManifests for LiteracyConfig { + fn generate_manifests( + &self, + course_root: &Path, + course_manifest: &CourseManifest, + _preferences: &UserPreferences, + ) -> Result { + // Collect all the examples and exceptions. First, gather the inlined ones. Then, gather the + // examples and exceptions from the files in the courses's root directory. + let mut examples = self.inlined_examples.clone(); + let mut exceptions = self.inlined_exceptions.clone(); + for entry in fs::read_dir(course_root)? { + // Ignore entries that are not a file. + let entry = entry.context("Failed to read entry when generating literacy course")?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + // Check that the file name ends is either an example or exception file. + let file_name = path.file_name().unwrap_or_default().to_str().unwrap(); + if file_name.ends_with(EXAMPLE_SUFFIX) { + let example = fs::read_to_string(&path).context("Failed to read example file")?; + examples.push(example); + } else if file_name.ends_with(EXCEPTION_SUFFIX) { + let exception = + fs::read_to_string(&path).context("Failed to read exception file")?; + exceptions.push(exception); + } + } + + // Sort the lists to have predictable outputs. + examples.sort(); + exceptions.sort(); + + // Generate the manifests for all the lessons and exercises and metadata to indicate this is + // a literacy course. + let lessons = Self::generate_lessons(course_manifest, &examples, &exceptions); + let mut metadata = course_manifest.metadata.clone().unwrap_or_default(); + metadata.insert(COURSE_METADATA.to_string(), vec!["true".to_string()]); + Ok(GeneratedCourse { + lessons, + updated_metadata: Some(metadata), + updated_instructions: None, + }) + } +} + +#[cfg(test)] +mod test { + use anyhow::Result; + use std::{collections::BTreeMap, fs, path::Path}; + + use crate::data::{ + course_generator::literacy::{LiteracyConfig, LiteracyLesson}, + CourseGenerator, CourseManifest, ExerciseAsset, ExerciseManifest, ExerciseType, + GenerateManifests, GeneratedCourse, LessonManifest, UserPreferences, + }; + + /// Writes the given number of example and exception files to the given directory. + fn generate_test_files(root_dir: &Path, num_examples: u8, num_exceptions: u8) -> Result<()> { + for i in 0..num_examples { + let example_file = root_dir.join(format!("example_{i}.example.md")); + let example_content = format!("example_{i}"); + fs::write(&example_file, example_content)?; + } + for i in 0..num_exceptions { + let exception_file = root_dir.join(format!("exception_{i}.exception.md")); + let exception_content = format!("exception_{i}"); + fs::write(&exception_file, exception_content)?; + } + Ok(()) + } + + /// Verifies generating a literacy course with a dictation lesson. + #[test] + fn test_generate_manifests_dictation() -> Result<()> { + // Create course manifest and files. + let config = CourseGenerator::Literacy(LiteracyConfig { + generate_dictation: true, + inlined_examples: vec![ + "inlined_example_0".to_string(), + "inlined_example_1".to_string(), + ], + inlined_exceptions: vec![ + "inlined_exception_0".to_string(), + "inlined_exception_1".to_string(), + ], + }); + let course_manifest = CourseManifest { + id: "literacy_course".into(), + name: "Literacy Course".into(), + dependencies: vec![], + superseded: vec![], + description: None, + authors: None, + metadata: None, + course_material: None, + course_instructions: None, + generator_config: Some(config.clone()), + }; + let temp_dir = tempfile::tempdir()?; + generate_test_files(temp_dir.path(), 2, 2)?; + + // Generate the manifests. + let prefs = UserPreferences::default(); + let got = config.generate_manifests(temp_dir.path(), &course_manifest, &prefs)?; + let want = GeneratedCourse { + lessons: vec![ + ( + LessonManifest { + id: "literacy_course::reading".into(), + dependencies: vec![], + superseded: vec![], + course_id: "literacy_course".into(), + name: "Literacy Course - Reading".into(), + description: None, + metadata: Some(BTreeMap::from([( + "literacy_lesson".to_string(), + vec!["reading".to_string()], + )])), + lesson_material: None, + lesson_instructions: None, + }, + vec![ExerciseManifest { + id: "literacy_course::reading::exercise".into(), + lesson_id: "literacy_course::reading".into(), + course_id: "literacy_course".into(), + name: "Literacy Course - Reading".into(), + description: None, + exercise_type: ExerciseType::Procedural, + exercise_asset: ExerciseAsset::LiteracyAsset { + lesson_type: LiteracyLesson::Reading, + examples: vec![ + "example_0".to_string(), + "example_1".to_string(), + "inlined_example_0".to_string(), + "inlined_example_1".to_string(), + ], + exceptions: vec![ + "exception_0".to_string(), + "exception_1".to_string(), + "inlined_exception_0".to_string(), + "inlined_exception_1".to_string(), + ], + }, + }], + ), + ( + LessonManifest { + id: "literacy_course::dictation".into(), + dependencies: vec!["literacy_course::reading".into()], + superseded: vec![], + course_id: "literacy_course".into(), + name: "Literacy Course - Dictation".into(), + description: None, + metadata: Some(BTreeMap::from([( + "literacy_lesson".to_string(), + vec!["dictation".to_string()], + )])), + lesson_material: None, + lesson_instructions: None, + }, + vec![ExerciseManifest { + id: "literacy_course::dictation::exercise".into(), + lesson_id: "literacy_course::dictation".into(), + course_id: "literacy_course".into(), + name: "Literacy Course - Dictation".into(), + description: None, + exercise_type: ExerciseType::Procedural, + exercise_asset: ExerciseAsset::LiteracyAsset { + lesson_type: LiteracyLesson::Dictation, + examples: vec![ + "example_0".to_string(), + "example_1".to_string(), + "inlined_example_0".to_string(), + "inlined_example_1".to_string(), + ], + exceptions: vec![ + "exception_0".to_string(), + "exception_1".to_string(), + "inlined_exception_0".to_string(), + "inlined_exception_1".to_string(), + ], + }, + }], + ), + ], + updated_metadata: Some(BTreeMap::from([( + "literacy_course".to_string(), + vec!["true".to_string()], + )])), + updated_instructions: None, + }; + assert_eq!(got, want); + Ok(()) + } + + /// Verifies generating a literacy course with no dictation lesson. + #[test] + fn test_generate_manifests_no_dictation() -> Result<()> { + // Create course manifest and files. + let config = CourseGenerator::Literacy(LiteracyConfig { + generate_dictation: false, + inlined_examples: vec![ + "inlined_example_0".to_string(), + "inlined_example_1".to_string(), + ], + inlined_exceptions: vec![ + "inlined_exception_0".to_string(), + "inlined_exception_1".to_string(), + ], + }); + let course_manifest = CourseManifest { + id: "literacy_course".into(), + name: "Literacy Course".into(), + dependencies: vec![], + superseded: vec![], + description: None, + authors: None, + metadata: None, + course_material: None, + course_instructions: None, + generator_config: Some(config.clone()), + }; + let temp_dir = tempfile::tempdir()?; + generate_test_files(temp_dir.path(), 2, 2)?; + + // Generate the manifests. + let prefs = UserPreferences::default(); + let got = config.generate_manifests(temp_dir.path(), &course_manifest, &prefs)?; + let want = GeneratedCourse { + lessons: vec![( + LessonManifest { + id: "literacy_course::reading".into(), + dependencies: vec![], + superseded: vec![], + course_id: "literacy_course".into(), + name: "Literacy Course - Reading".into(), + description: None, + metadata: Some(BTreeMap::from([( + "literacy_lesson".to_string(), + vec!["reading".to_string()], + )])), + lesson_material: None, + lesson_instructions: None, + }, + vec![ExerciseManifest { + id: "literacy_course::reading::exercise".into(), + lesson_id: "literacy_course::reading".into(), + course_id: "literacy_course".into(), + name: "Literacy Course - Reading".into(), + description: None, + exercise_type: ExerciseType::Procedural, + exercise_asset: ExerciseAsset::LiteracyAsset { + lesson_type: LiteracyLesson::Reading, + examples: vec![ + "example_0".to_string(), + "example_1".to_string(), + "inlined_example_0".to_string(), + "inlined_example_1".to_string(), + ], + exceptions: vec![ + "exception_0".to_string(), + "exception_1".to_string(), + "inlined_exception_0".to_string(), + "inlined_exception_1".to_string(), + ], + }, + }], + )], + updated_metadata: Some(BTreeMap::from([( + "literacy_course".to_string(), + vec!["true".to_string()], + )])), + updated_instructions: None, + }; + assert_eq!(got, want); + Ok(()) + } +} diff --git a/src/data/course_generator/transcription/constants.rs b/src/data/course_generator/transcription/constants.rs index 91aa5cc..7eaf7a5 100644 --- a/src/data/course_generator/transcription/constants.rs +++ b/src/data/course_generator/transcription/constants.rs @@ -39,8 +39,7 @@ pub const ADVANCED_TRANSCRIPTION_DESCRIPTION: &str = indoc! {" Refer to the lesson instructions for more details. "}; -/// The metadata key indicating the lesson belongs to a transcription course. Its value should be -/// set to "true". +/// The metadata key indicating this is a transcription course. Its value should be set to "true". pub const COURSE_METADATA: &str = "transcription_course"; /// The metadata key indicating the type of the transcription lesson. Its value should be set to