• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

trane-project / trane / 13581533471

28 Feb 2025 04:47AM UTC coverage: 99.445% (-0.2%) from 99.674%
13581533471

Pull #323

github

web-flow
Merge 291814579 into e3080ce1d
Pull Request #323: Revamp literacy course

205 of 218 new or added lines in 1 file covered. (94.04%)

2 existing lines in 2 files now uncovered.

5016 of 5044 relevant lines covered (99.44%)

151287.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.07
/src/data/course_generator/literacy.rs
1
//! Defines a special course to teach literacy skills.
2
//!
3
//! The student is presented with examples and exceptions that match a certain spelling rule or type
4
//! of reading material. They are asked to read the example and exceptions and are scored based on
5
//! how many they get right. Optionally, a dictation lesson can be generated where the student is
6
//! asked to write the examples and exceptions based on the tutor's dictation.
7

8
use anyhow::{anyhow, Context, Error, Result};
9
use serde::{de::DeserializeOwned, Deserialize, Serialize};
10
use std::{
11
    collections::BTreeMap,
12
    fs::{read_dir, File},
13
    io::{BufReader, Read},
14
    path::Path,
15
};
16
use ts_rs::TS;
17
use ustr::{Ustr, UstrMap, UstrSet};
18

19
use crate::data::{
20
    BasicAsset, CourseGenerator, CourseManifest, ExerciseAsset, ExerciseManifest, ExerciseType,
21
    GenerateManifests, GeneratedCourse, LessonManifest, UserPreferences,
22
};
23

24
/// The metadata key indicating this is a literacy course. Its value should be set to "true".
25
pub const COURSE_METADATA: &str = "literacy_course";
26

27
/// The suffix used to recognize a directory as a knowledge base lesson.
28
pub const LESSON_SUFFIX: &str = ".lesson";
29

30
/// The name of the file containing the dependencies of a lesson.
31
pub const LESSON_DEPENDENCIES_FILE: &str = "lesson.dependencies.json";
32

33
/// The name of the file containing the name of a lesson.
34
pub const LESSON_NAME_FILE: &str = "lesson.name.json";
35

36
/// The name of the file containing the description of a lesson.
37
pub const LESSON_DESCRIPTION_FILE: &str = "lesson.description.json";
38

39
/// The name of the file containing the lesson instructions.
40
pub const LESSON_INSTRUCTIONS_FILE: &str = "lesson.instructions.md";
41

42
/// The name of the file containing the lesson material.
43
pub const LESSON_MATERIAL_FILE: &str = "lesson.material.md";
44

45
/// The metadata indicating the type of literacy lesson.
46
pub const LESSON_METADATA: &str = "literacy_lesson";
47

48
/// The extension of files containing examples.
49
pub const EXAMPLE_SUFFIX: &str = ".example.md";
50

51
/// The extension of files containing exceptions.
52
pub const EXCEPTION_SUFFIX: &str = ".exception.md";
53

54
/// The name of the file containing a list of examples.
55
pub const SIMPLE_EXAMPLES_FILE: &str = "simple_examples.md";
56

57
/// The name of the file containing a list of exceptions.
58
pub const SIMPLE_EXCEPTIONS_FILE: &str = "simple_exceptions.md";
59

60
/// An enum representing a type of files that can be found in a literacy lesson directory.
61
#[derive(Debug, Eq, PartialEq)]
62
pub enum LiteracyFile {
63
    /// The file containing the name of the lesson.
64
    LessonName,
65

66
    /// The file containing the description of the lesson.
67
    LessonDescription,
68

69
    /// The file containing the dependencies of the lesson.
70
    LessonDependencies,
71

72
    /// The file containing the lesson instructions.
73
    LessonInstructions,
74

75
    /// The file containing the front of the flashcard for the exercise with the given short ID.
76
    Example(String),
77

78
    /// The file containing the back of the flashcard for the exercise with the given short ID.
79
    Exception(String),
80

81
    /// The file containing one example per line.
82
    SimpleExamples,
83

84
    /// The file containing one exception per line.
85
    SimpleExceptions,
86
}
87

88
impl LiteracyFile {
89
    /// Opens the knowledge base file at the given path and deserializes its contents.
90
    pub fn open_serialized<T: DeserializeOwned>(path: &Path) -> Result<T> {
2✔
91
        let display = path.display();
2✔
92
        let file = File::open(path).context(format!("cannot open literacy file {display}"))?;
2✔
93
        let reader = BufReader::new(file);
2✔
94
        serde_json::from_reader(reader).context(format!("cannot parse literacy file {display}"))
2✔
95
    }
2✔
96

97
    /// Opens a file that contains an example or exception stored as markdown.
98
    pub fn open_md(path: &Path) -> Result<String> {
16✔
99
        let display = path.display();
16✔
100
        let file =
16✔
101
            File::open(path).context(format!("cannot open literacy markdown file {display}"))?;
16✔
102
        let mut reader = BufReader::new(file);
16✔
103
        let mut contents = String::new();
16✔
104
        reader
16✔
105
            .read_to_string(&mut contents)
16✔
106
            .context(format!("cannot read literacy markdown file {display}"))?;
16✔
107
        Ok(contents)
16✔
108
    }
16✔
109

110
    /// Opens a file that contains one example or exception per line.
111
    pub fn open_md_list(path: &Path) -> Result<Vec<String>> {
8✔
112
        let display = path.display();
8✔
113
        let file =
8✔
114
            File::open(path).context(format!("cannot open literacy markdown file {display}"))?;
8✔
115
        let mut reader = BufReader::new(file);
8✔
116
        let mut contents = String::new();
8✔
117
        reader
8✔
118
            .read_to_string(&mut contents)
8✔
119
            .context(format!("cannot read literacy markdown file {display}"))?;
8✔
120
        Ok(contents
8✔
121
            .lines()
8✔
122
            .map(ToString::to_string)
8✔
123
            .map(|s| s.trim().to_string())
16✔
124
            .filter(|s| !s.is_empty())
16✔
125
            .collect())
8✔
126
    }
8✔
127
}
128

129
impl TryFrom<&str> for LiteracyFile {
130
    type Error = Error;
131

132
    /// Converts a file name to a `KnowledgeBaseFile` variant.
133
    fn try_from(file_name: &str) -> Result<Self> {
26✔
134
        match file_name {
16✔
135
            LESSON_DEPENDENCIES_FILE => Ok(LiteracyFile::LessonDependencies),
26✔
136
            LESSON_NAME_FILE => Ok(LiteracyFile::LessonName),
24✔
137
            LESSON_DESCRIPTION_FILE => Ok(LiteracyFile::LessonDescription),
24✔
138
            LESSON_INSTRUCTIONS_FILE => Ok(LiteracyFile::LessonInstructions),
24✔
139
            file_name if file_name.ends_with(EXAMPLE_SUFFIX) => {
24✔
140
                let short_id = file_name.strip_suffix(EXAMPLE_SUFFIX).unwrap();
8✔
141
                Ok(LiteracyFile::Example(short_id.to_string()))
8✔
142
            }
143
            file_name if file_name.ends_with(EXCEPTION_SUFFIX) => {
16✔
144
                let short_id = file_name.strip_suffix(EXCEPTION_SUFFIX).unwrap();
8✔
145
                Ok(LiteracyFile::Exception(short_id.to_string()))
8✔
146
            }
147
            SIMPLE_EXAMPLES_FILE => Ok(LiteracyFile::SimpleExamples),
8✔
148
            SIMPLE_EXCEPTIONS_FILE => Ok(LiteracyFile::SimpleExceptions),
4✔
NEW
149
            _ => Err(anyhow!("Not a valid literacy file name: {}", file_name)),
×
150
        }
151
    }
26✔
152
}
153

154
/// The types of literacy lessons that can be generated.
155
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
31✔
156
#[ts(export)]
157
pub enum LiteracyLessonType {
158
    /// A lesson that takes examples and exceptions and asks the student to read them.
159
    Reading,
160

161
    /// A lesson that takes examples and exceptions and asks the student to write them based on the
162
    /// tutor's dictation.
163
    Dictation,
164
}
165

166
/// A representation of a literacy lesson containing examples and exceptions from which the raw
167
/// lesson and exercise manifests are generated.
168
///
169
/// In a literacy course, lessons are generated by searching for all directories with a name in the
170
/// format `<short_id>.lesson`. Examples are read from files with the suffix `.example.md`. The
171
/// optional exceptions are read from files with the suffix `.exception.md`.
172
///
173
/// Simple example and exceptions can be added by reading examples from the file
174
/// `simple_examples.md` and exceptions from the file `simple_exceptions.md`. Each line of these
175
/// files is treated as a separate example or exception.
176
///
177
/// Additional fields like the name and dependencies of the lesson can be set by creating a file
178
/// named `lesson.<PROPERTY_NAME>.json` in the lesson directory with the serialized value of the
179
/// property.
180
///
181
/// An instruction file can be created by creating a file named `lesson.instructions.md` in the
182
/// lesson directory.
183
pub struct LiteracyLesson {
184
    /// The short ID of the lesson, which is used to easily identify the lesson and to generate the
185
    /// final lesson ID.
186
    pub short_id: Ustr,
187

188
    /// The IDs of all dependencies of this lesson. The values can be full lesson IDs or the short
189
    /// ID of one of the other lessons in the course. If Trane finds a dependency with a short ID,
190
    /// it will automatically generate the full lesson ID. Not setting this value will indicate that
191
    /// the lesson has no dependencies.
192
    pub dependencies: Vec<Ustr>,
193

194
    /// The name of the lesson to be presented to the user.
195
    pub name: Option<String>,
196

197
    /// An optional description of the lesson.
198
    pub description: Option<String>,
199

200
    /// Optional instructions for the lesson.
201
    pub instructions: Option<BasicAsset>,
202

203
    /// The examples for the lesson.
204
    pub examples: Vec<String>,
205

206
    /// The exceptions for the lesson.
207
    pub exceptions: Vec<String>,
208
}
209

210
impl LiteracyLesson {
211
    /// Generates the lesson from a list of literacy files.
212
    fn create_lesson(
4✔
213
        lesson_root: &Path,
4✔
214
        short_lesson_id: Ustr,
4✔
215
        files: &[LiteracyFile],
4✔
216
    ) -> Result<Self> {
4✔
217
        // Create the lesson with all the optional fields set to a default value.
4✔
218
        let mut lesson = Self {
4✔
219
            short_id: short_lesson_id,
4✔
220
            dependencies: vec![],
4✔
221
            name: None,
4✔
222
            description: None,
4✔
223
            instructions: None,
4✔
224
            examples: vec![],
4✔
225
            exceptions: vec![],
4✔
226
        };
4✔
227

228
        // Iterate through the lesson files found in the lesson directory and update the
229
        // corresponding field in the lesson.
230
        for lesson_file in files {
30✔
231
            match lesson_file {
26✔
232
                LiteracyFile::LessonDependencies => {
233
                    let path = lesson_root.join(LESSON_DEPENDENCIES_FILE);
2✔
234
                    lesson.dependencies = LiteracyFile::open_serialized(&path)?;
2✔
235
                }
236
                LiteracyFile::LessonName => {
NEW
237
                    let path = lesson_root.join(LESSON_NAME_FILE);
×
NEW
238
                    lesson.name = Some(LiteracyFile::open_serialized(&path)?);
×
239
                }
240
                LiteracyFile::LessonDescription => {
NEW
241
                    let path = lesson_root.join(LESSON_DESCRIPTION_FILE);
×
NEW
242
                    lesson.description = Some(LiteracyFile::open_serialized(&path)?);
×
243
                }
244
                LiteracyFile::LessonInstructions => {
NEW
245
                    let path = lesson_root.join(LESSON_INSTRUCTIONS_FILE);
×
NEW
246
                    lesson.instructions = Some(BasicAsset::InlinedAsset {
×
NEW
247
                        content: LiteracyFile::open_md(&path)?,
×
248
                    });
249
                }
250
                LiteracyFile::Example(short_id) => {
8✔
251
                    let path = lesson_root.join(format!("{short_id}{EXAMPLE_SUFFIX}"));
8✔
252
                    let example = LiteracyFile::open_md(&path)?;
8✔
253
                    lesson.examples.push(example);
8✔
254
                }
255
                LiteracyFile::Exception(short_id) => {
8✔
256
                    let path = lesson_root.join(format!("{short_id}{EXCEPTION_SUFFIX}"));
8✔
257
                    let exception = LiteracyFile::open_md(&path)?;
8✔
258
                    lesson.exceptions.push(exception);
8✔
259
                }
260
                LiteracyFile::SimpleExamples => {
261
                    let path = lesson_root.join(SIMPLE_EXAMPLES_FILE);
4✔
262
                    let examples = LiteracyFile::open_md_list(&path)?;
4✔
263
                    lesson.examples.extend(examples);
4✔
264
                }
265
                LiteracyFile::SimpleExceptions => {
266
                    let path = lesson_root.join(SIMPLE_EXCEPTIONS_FILE);
4✔
267
                    let exceptions = LiteracyFile::open_md_list(&path)?;
4✔
268
                    lesson.exceptions.extend(exceptions);
4✔
269
                }
270
            }
271
        }
272

273
        // Examples and exceptions are sorted to have predictable outputs.
274
        lesson.examples.sort();
4✔
275
        lesson.exceptions.sort();
4✔
276
        Ok(lesson)
4✔
277
    }
4✔
278

279
    /// Opens a literacy lesson from the given directory.
280
    fn open_lesson(lesson_root: &Path, short_lesson_id: Ustr) -> Result<Self> {
4✔
281
        // Iterate through the directory to find all the matching files in the lesson directory.
282
        let lesson_files = read_dir(lesson_root)?
4✔
283
            .flatten()
4✔
284
            .flat_map(|entry| {
26✔
285
                LiteracyFile::try_from(entry.file_name().to_str().unwrap_or_default())
26✔
286
            })
26✔
287
            .collect::<Vec<_>>();
4✔
288

4✔
289
        // Create the literacy lesson.
4✔
290
        Self::create_lesson(lesson_root, short_lesson_id, &lesson_files)
4✔
291
    }
4✔
292

293
    /// Detectes whether the given ID is one of the short IDs for one of the lesson of the course
294
    /// and returns the full ID of the reading lesson. Otherwise, it returns the ID as is.
295
    fn full_reading_lesson_id(course_id: Ustr, lesson_id: Ustr, short_ids: &UstrSet) -> Ustr {
10✔
296
        if short_ids.contains(&lesson_id) {
10✔
297
            let full_id = format!("{course_id}::{lesson_id}::reading");
9✔
298
            full_id.into()
9✔
299
        } else {
300
            lesson_id
1✔
301
        }
302
    }
10✔
303

304
    /// Detects whether the given ID is one of the short IDs for one of the lesson of the course
305
    /// and returns the full ID of the dictation lesson. Otherwise, it returns the ID as is.k
306
    fn full_dictation_lesson_id(course_id: Ustr, lesson_id: Ustr, short_ids: &UstrSet) -> Ustr {
4✔
307
        if short_ids.contains(&lesson_id) {
4✔
308
            let full_id = format!("{course_id}::{lesson_id}::dictation");
3✔
309
            full_id.into()
3✔
310
        } else {
311
            lesson_id
1✔
312
        }
313
    }
4✔
314

315
    // Returns the name of the course, returning the ID if the name is empty.
316
    fn course_name(course_manifest: &CourseManifest) -> String {
6✔
317
        if course_manifest.name.is_empty() {
6✔
NEW
318
            course_manifest.id.to_string()
×
319
        } else {
320
            course_manifest.name.clone()
6✔
321
        }
322
    }
6✔
323

324
    // Retuns the name of the lesson, returning a sane default if the name is empty.
325
    fn lesson_name(&self, course_name: &str, lesson_type: &LiteracyLessonType) -> String {
6✔
326
        let lesson_type = match lesson_type {
6✔
327
            LiteracyLessonType::Reading => "Reading",
4✔
328
            LiteracyLessonType::Dictation => "Dictation",
2✔
329
        };
330
        if let Some(name) = &self.name {
6✔
NEW
331
            format!("{course_name} - {name} - {lesson_type}")
×
332
        } else {
333
            format!("{course_name} - {} - {lesson_type}", self.short_id)
6✔
334
        }
335
    }
6✔
336

337
    /// Generates the manifests for the reading lesson.
338
    fn generate_reading_lesson(
4✔
339
        &self,
4✔
340
        course_manifest: &CourseManifest,
4✔
341
        short_id: Ustr,
4✔
342
        short_ids: &UstrSet,
4✔
343
    ) -> (LessonManifest, Vec<ExerciseManifest>) {
4✔
344
        // Generate basic info for the lesson.
4✔
345
        let lesson_id = Self::full_reading_lesson_id(course_manifest.id, short_id, short_ids);
4✔
346
        let dependencies = self
4✔
347
            .dependencies
4✔
348
            .iter()
4✔
349
            .map(|id| Self::full_reading_lesson_id(course_manifest.id, *id, short_ids))
4✔
350
            .collect::<Vec<_>>();
4✔
351
        let course_name = Self::course_name(course_manifest);
4✔
352
        let lesson_name = self.lesson_name(&course_name, &LiteracyLessonType::Reading);
4✔
353
        let description = if self.description.is_some() {
4✔
NEW
354
            self.description.clone()
×
355
        } else {
356
            None
4✔
357
        };
358

359
        // Create the lesson manifest.
360
        let lesson_manifest = LessonManifest {
4✔
361
            id: lesson_id,
4✔
362
            dependencies,
4✔
363
            superseded: vec![],
4✔
364
            course_id: course_manifest.id,
4✔
365
            name: lesson_name.clone(),
4✔
366
            description: description.clone(),
4✔
367
            metadata: Some(BTreeMap::from([(
4✔
368
                LESSON_METADATA.to_string(),
4✔
369
                vec!["reading".to_string()],
4✔
370
            )])),
4✔
371
            lesson_instructions: self.instructions.clone(),
4✔
372
            lesson_material: None,
4✔
373
        };
4✔
374

4✔
375
        // Create the exercise manifest.
4✔
376
        let exercise_manifest = ExerciseManifest {
4✔
377
            id: format!("{lesson_id}::exercise").into(),
4✔
378
            lesson_id: lesson_manifest.id,
4✔
379
            course_id: course_manifest.id,
4✔
380
            name: lesson_name,
4✔
381
            description,
4✔
382
            exercise_type: ExerciseType::Procedural,
4✔
383
            exercise_asset: ExerciseAsset::LiteracyAsset {
4✔
384
                lesson_type: LiteracyLessonType::Reading,
4✔
385
                examples: self.examples.clone(),
4✔
386
                exceptions: self.exceptions.clone(),
4✔
387
            },
4✔
388
        };
4✔
389
        (lesson_manifest, vec![exercise_manifest])
4✔
390
    }
4✔
391

392
    /// Generates the manifests for the dictation lesson.
393
    fn generate_dictation_lesson(
2✔
394
        &self,
2✔
395
        course_manifest: &CourseManifest,
2✔
396
        short_id: Ustr,
2✔
397
        short_ids: &UstrSet,
2✔
398
    ) -> (LessonManifest, Vec<ExerciseManifest>) {
2✔
399
        // Generate basic info for the lesson.
2✔
400
        let lesson_id = Self::full_dictation_lesson_id(course_manifest.id, short_id, short_ids);
2✔
401
        let reading_lesson_id =
2✔
402
            Self::full_reading_lesson_id(course_manifest.id, short_id, short_ids);
2✔
403
        let course_name = Self::course_name(course_manifest);
2✔
404
        let lesson_name = self.lesson_name(&course_name, &LiteracyLessonType::Dictation);
2✔
405
        let description = if self.description.is_some() {
2✔
NEW
406
            self.description.clone()
×
407
        } else {
408
            None
2✔
409
        };
410

411
        // Create the lesson manifest.
412
        let lesson_manifest = LessonManifest {
2✔
413
            id: lesson_id,
2✔
414
            dependencies: vec![reading_lesson_id],
2✔
415
            superseded: vec![],
2✔
416
            course_id: course_manifest.id,
2✔
417
            name: lesson_name.clone(),
2✔
418
            description: description.clone(),
2✔
419
            metadata: Some(BTreeMap::from([(
2✔
420
                LESSON_METADATA.to_string(),
2✔
421
                vec!["dictation".to_string()],
2✔
422
            )])),
2✔
423
            lesson_instructions: self.instructions.clone(),
2✔
424
            lesson_material: None,
2✔
425
        };
2✔
426

2✔
427
        // Create the exercise manifest.
2✔
428
        let exercise_manifest = ExerciseManifest {
2✔
429
            id: format!("{lesson_id}::exercise").into(),
2✔
430
            lesson_id: lesson_manifest.id,
2✔
431
            course_id: course_manifest.id,
2✔
432
            name: lesson_name,
2✔
433
            description,
2✔
434
            exercise_type: ExerciseType::Procedural,
2✔
435
            exercise_asset: ExerciseAsset::LiteracyAsset {
2✔
436
                lesson_type: LiteracyLessonType::Dictation,
2✔
437
                examples: self.examples.clone(),
2✔
438
                exceptions: self.exceptions.clone(),
2✔
439
            },
2✔
440
        };
2✔
441
        (lesson_manifest, vec![exercise_manifest])
2✔
442
    }
2✔
443

444
    /// Generates the manifests for the reading and dictation lessons.
445
    fn generate_manifests(
4✔
446
        &self,
4✔
447
        course_manifest: &CourseManifest,
4✔
448
        short_id: Ustr,
4✔
449
        short_ids: &UstrSet,
4✔
450
    ) -> Vec<(LessonManifest, Vec<ExerciseManifest>)> {
4✔
451
        let mut generate_dictation = false;
4✔
452
        if let Some(CourseGenerator::Literacy(config)) = &course_manifest.generator_config {
4✔
453
            generate_dictation = config.generate_dictation;
4✔
454
        }
4✔
455

456
        if generate_dictation {
4✔
457
            vec![
2✔
458
                self.generate_reading_lesson(course_manifest, short_id, short_ids),
2✔
459
                self.generate_dictation_lesson(course_manifest, short_id, short_ids),
2✔
460
            ]
2✔
461
        } else {
462
            vec![self.generate_reading_lesson(course_manifest, short_id, short_ids)]
2✔
463
        }
464
    }
4✔
465
}
466

467
/// The configuration to create a course that teaches literacy based on the provided material.
468
/// Material can be of two types.
469
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
31✔
470
#[ts(export)]
471
pub struct LiteracyConfig {
472
    /// Indicates whether to generate a lesson that asks the student to write the examples and
473
    /// exceptions based on the tutor's dictation.
474
    #[serde(default)]
475
    pub generate_dictation: bool,
476
}
477

478
impl GenerateManifests for LiteracyConfig {
479
    fn generate_manifests(
2✔
480
        &self,
2✔
481
        course_root: &Path,
2✔
482
        course_manifest: &CourseManifest,
2✔
483
        _preferences: &UserPreferences,
2✔
484
    ) -> Result<GeneratedCourse> {
2✔
485
        // Create the lessons by iterating through all the directories in the course root,
2✔
486
        // processing only those whose name fits the pattern `<SHORT_LESSON_ID>.lesson`.
2✔
487
        let mut lessons = UstrMap::default();
2✔
488
        let valid_entries = read_dir(course_root)?
2✔
489
            .flatten()
2✔
490
            .filter(|entry| {
4✔
491
                let path = entry.path();
4✔
492
                path.is_dir()
4✔
493
            })
4✔
494
            .collect::<Vec<_>>();
2✔
495
        for entry in valid_entries {
6✔
496
            // Check if the directory name is in the format `<SHORT_LESSON_ID>.lesson`. If so, read
497
            // the knowledge base lesson and its exercises.
498
            let path = entry.path();
4✔
499
            let dir_name = path.file_name().unwrap_or_default().to_str().unwrap();
4✔
500
            if let Some(short_id) = dir_name.strip_suffix(LESSON_SUFFIX) {
4✔
501
                if !short_id.is_empty() {
4✔
502
                    lessons.insert(
4✔
503
                        short_id.into(),
4✔
504
                        LiteracyLesson::open_lesson(&path, short_id.into())?,
4✔
505
                    );
NEW
506
                }
×
UNCOV
507
            }
×
508
        }
509

510
        // Create the manifests.
511
        let short_ids: UstrSet = lessons.keys().copied().collect();
2✔
512
        let lessons: Vec<(LessonManifest, Vec<ExerciseManifest>)> = lessons
2✔
513
            .into_iter()
2✔
514
            .flat_map(|(short_id, lesson)| {
4✔
515
                lesson.generate_manifests(course_manifest, short_id, &short_ids)
4✔
516
            })
4✔
517
            .collect();
2✔
518
        let mut metadata = course_manifest.metadata.clone().unwrap_or_default();
2✔
519
        metadata.insert(COURSE_METADATA.to_string(), vec!["true".to_string()]);
2✔
520
        Ok(GeneratedCourse {
2✔
521
            lessons,
2✔
522
            updated_metadata: Some(metadata),
2✔
523
            updated_instructions: None,
2✔
524
        })
2✔
525
    }
2✔
526
}
527

528
#[cfg(test)]
529
#[cfg_attr(coverage, coverage(off))]
530
mod test {
531
    use anyhow::Result;
532
    use pretty_assertions::assert_eq;
533
    use std::{collections::BTreeMap, fs, path::Path};
534
    use ustr::{Ustr, UstrSet};
535

536
    use crate::data::{
537
        course_generator::literacy::{LiteracyConfig, LiteracyLesson, LiteracyLessonType},
538
        CourseGenerator, CourseManifest, ExerciseAsset, ExerciseManifest, ExerciseType,
539
        GenerateManifests, GeneratedCourse, LessonManifest, UserPreferences,
540
    };
541

542
    /// Verifies that lesson IDs are generated correctly.
543
    #[test]
544
    fn full_lesson_ids() {
545
        let course_id = Ustr::from("course_id");
546
        let short_id = Ustr::from("lesson_id");
547
        let not_in_short_ids = "other_course_id::other_lesson_id".into();
548
        let short_ids: UstrSet = vec!["lesson_id".into()].into_iter().collect();
549

550
        // Reading lesson is one of the short IDs.
551
        let reading_lesson_id =
552
            LiteracyLesson::full_reading_lesson_id(course_id, short_id, &short_ids);
553
        assert_eq!(
554
            reading_lesson_id,
555
            Ustr::from("course_id::lesson_id::reading"),
556
        );
557

558
        // Reading lesson is not one of the short IDs.
559
        let reading_lesson_id =
560
            LiteracyLesson::full_reading_lesson_id(course_id, not_in_short_ids, &short_ids);
561
        assert_eq!(
562
            reading_lesson_id,
563
            Ustr::from("other_course_id::other_lesson_id")
564
        );
565

566
        // Dictation lesson is one of the short IDs.
567
        let dictation_lesson_id =
568
            LiteracyLesson::full_dictation_lesson_id(course_id, short_id, &short_ids);
569
        assert_eq!(
570
            dictation_lesson_id,
571
            Ustr::from("course_id::lesson_id::dictation"),
572
        );
573

574
        // Dictation lesson is not one of the short IDs.
575
        let dictation_lesson_id =
576
            LiteracyLesson::full_dictation_lesson_id(course_id, not_in_short_ids, &short_ids);
577
        assert_eq!(
578
            dictation_lesson_id,
579
            Ustr::from("other_course_id::other_lesson_id")
580
        );
581
    }
582

583
    /// Generates a set of test lessons, each with the given number of examples and exceptions.
584
    /// Each lesson will depend on the previous one to verify the generation of dependencies.
585
    fn generate_test_files(
586
        root_dir: &Path,
587
        num_lessons: u8,
588
        num_examples: u8,
589
        num_exceptions: u8,
590
        num_simple_examples: u8,
591
        num_simple_exceptions: u8,
592
    ) -> Result<()> {
593
        for i in 0..num_lessons {
594
            // Create the lesson directory and make lesson depend on the previous one.
595
            let lesson_dir = root_dir.join(format!("lesson_{i}.lesson"));
596
            fs::create_dir_all(&lesson_dir)?;
597
            if i > 0 {
598
                let dependencies_file = lesson_dir.join("lesson.dependencies.json");
599
                let dependencies_content = format!("[\"lesson_{}\"]", i - 1);
600
                fs::write(&dependencies_file, dependencies_content)?;
601
            }
602

603
            // Write individual example and exception files.
604
            for j in 0..num_examples {
605
                let example_file = lesson_dir.join(format!("example_{j}.example.md"));
606
                let example_content = format!("example_{j}");
607
                fs::write(&example_file, example_content)?;
608
            }
609
            for j in 0..num_exceptions {
610
                let exception_file = lesson_dir.join(format!("exception_{j}.exception.md"));
611
                let exception_content = format!("exception_{j}");
612
                fs::write(&exception_file, exception_content)?;
613
            }
614

615
            // If simple examples and exceptions are requested, generate the `simple_examples.md`
616
            // and `simple_exceptions.md` files.
617
            if num_simple_examples > 0 {
618
                let simple_example_file = lesson_dir.join("simple_examples.md");
619
                let simple_example_content = (0..num_simple_examples)
620
                    .map(|j| format!("simple_example_{j}"))
621
                    .collect::<Vec<_>>()
622
                    .join("\n");
623
                fs::write(&simple_example_file, simple_example_content)?;
624
            }
625
            if num_simple_exceptions > 0 {
626
                let simple_exception_file = lesson_dir.join("simple_exceptions.md");
627
                let simple_exception_content = (0..num_simple_exceptions)
628
                    .map(|j| format!("simple_exception_{j}"))
629
                    .collect::<Vec<_>>()
630
                    .join("\n");
631
                fs::write(&simple_exception_file, simple_exception_content)?;
632
            }
633
        }
634
        Ok(())
635
    }
636

637
    /// Verifies generating a literacy course with a dictation lesson.
638
    #[test]
639
    fn test_generate_manifests_dictation() -> Result<()> {
640
        // Create course manifest and files.
641
        let config = CourseGenerator::Literacy(LiteracyConfig {
642
            generate_dictation: true,
643
        });
644
        let course_manifest = CourseManifest {
645
            id: "literacy_course".into(),
646
            name: "Literacy Course".into(),
647
            dependencies: vec![],
648
            superseded: vec![],
649
            description: None,
650
            authors: None,
651
            metadata: None,
652
            course_material: None,
653
            course_instructions: None,
654
            generator_config: Some(config.clone()),
655
        };
656
        let temp_dir = tempfile::tempdir()?;
657
        generate_test_files(temp_dir.path(), 2, 2, 2, 2, 2)?;
658

659
        // Generate the manifests. Sort lessons and exercises by ID to have predictable outputs.
660
        let prefs = UserPreferences::default();
661
        let mut got = config.generate_manifests(temp_dir.path(), &course_manifest, &prefs)?;
662
        got.lessons.sort_by(|a, b| a.0.id.cmp(&b.0.id));
663
        for (_, exercises) in &mut got.lessons {
664
            exercises.sort_by(|a, b| a.id.cmp(&b.id));
665
        }
666

667
        // Verify the generated course.
668
        let want = GeneratedCourse {
669
            lessons: vec![
670
                (
671
                    LessonManifest {
672
                        id: "literacy_course::lesson_0::dictation".into(),
673
                        dependencies: vec!["literacy_course::lesson_0::reading".into()],
674
                        superseded: vec![],
675
                        course_id: "literacy_course".into(),
676
                        name: "Literacy Course - lesson_0 - Dictation".into(),
677
                        description: None,
678
                        metadata: Some(BTreeMap::from([(
679
                            "literacy_lesson".to_string(),
680
                            vec!["dictation".to_string()],
681
                        )])),
682
                        lesson_material: None,
683
                        lesson_instructions: None,
684
                    },
685
                    vec![ExerciseManifest {
686
                        id: "literacy_course::lesson_0::dictation::exercise".into(),
687
                        lesson_id: "literacy_course::lesson_0::dictation".into(),
688
                        course_id: "literacy_course".into(),
689
                        name: "Literacy Course - lesson_0 - Dictation".into(),
690
                        description: None,
691
                        exercise_type: ExerciseType::Procedural,
692
                        exercise_asset: ExerciseAsset::LiteracyAsset {
693
                            lesson_type: LiteracyLessonType::Dictation,
694
                            examples: vec![
695
                                "example_0".to_string(),
696
                                "example_1".to_string(),
697
                                "simple_example_0".to_string(),
698
                                "simple_example_1".to_string(),
699
                            ],
700
                            exceptions: vec![
701
                                "exception_0".to_string(),
702
                                "exception_1".to_string(),
703
                                "simple_exception_0".to_string(),
704
                                "simple_exception_1".to_string(),
705
                            ],
706
                        },
707
                    }],
708
                ),
709
                (
710
                    LessonManifest {
711
                        id: "literacy_course::lesson_0::reading".into(),
712
                        dependencies: vec![],
713
                        superseded: vec![],
714
                        course_id: "literacy_course".into(),
715
                        name: "Literacy Course - lesson_0 - Reading".into(),
716
                        description: None,
717
                        metadata: Some(BTreeMap::from([(
718
                            "literacy_lesson".to_string(),
719
                            vec!["reading".to_string()],
720
                        )])),
721
                        lesson_material: None,
722
                        lesson_instructions: None,
723
                    },
724
                    vec![ExerciseManifest {
725
                        id: "literacy_course::lesson_0::reading::exercise".into(),
726
                        lesson_id: "literacy_course::lesson_0::reading".into(),
727
                        course_id: "literacy_course".into(),
728
                        name: "Literacy Course - lesson_0 - Reading".into(),
729
                        description: None,
730
                        exercise_type: ExerciseType::Procedural,
731
                        exercise_asset: ExerciseAsset::LiteracyAsset {
732
                            lesson_type: LiteracyLessonType::Reading,
733
                            examples: vec![
734
                                "example_0".to_string(),
735
                                "example_1".to_string(),
736
                                "simple_example_0".to_string(),
737
                                "simple_example_1".to_string(),
738
                            ],
739
                            exceptions: vec![
740
                                "exception_0".to_string(),
741
                                "exception_1".to_string(),
742
                                "simple_exception_0".to_string(),
743
                                "simple_exception_1".to_string(),
744
                            ],
745
                        },
746
                    }],
747
                ),
748
                (
749
                    LessonManifest {
750
                        id: "literacy_course::lesson_1::dictation".into(),
751
                        dependencies: vec!["literacy_course::lesson_1::reading".into()],
752
                        superseded: vec![],
753
                        course_id: "literacy_course".into(),
754
                        name: "Literacy Course - lesson_1 - Dictation".into(),
755
                        description: None,
756
                        metadata: Some(BTreeMap::from([(
757
                            "literacy_lesson".to_string(),
758
                            vec!["dictation".to_string()],
759
                        )])),
760
                        lesson_material: None,
761
                        lesson_instructions: None,
762
                    },
763
                    vec![ExerciseManifest {
764
                        id: "literacy_course::lesson_1::dictation::exercise".into(),
765
                        lesson_id: "literacy_course::lesson_1::dictation".into(),
766
                        course_id: "literacy_course".into(),
767
                        name: "Literacy Course - lesson_1 - Dictation".into(),
768
                        description: None,
769
                        exercise_type: ExerciseType::Procedural,
770
                        exercise_asset: ExerciseAsset::LiteracyAsset {
771
                            lesson_type: LiteracyLessonType::Dictation,
772
                            examples: vec![
773
                                "example_0".to_string(),
774
                                "example_1".to_string(),
775
                                "simple_example_0".to_string(),
776
                                "simple_example_1".to_string(),
777
                            ],
778
                            exceptions: vec![
779
                                "exception_0".to_string(),
780
                                "exception_1".to_string(),
781
                                "simple_exception_0".to_string(),
782
                                "simple_exception_1".to_string(),
783
                            ],
784
                        },
785
                    }],
786
                ),
787
                (
788
                    LessonManifest {
789
                        id: "literacy_course::lesson_1::reading".into(),
790
                        dependencies: vec!["literacy_course::lesson_0::reading".into()],
791
                        superseded: vec![],
792
                        course_id: "literacy_course".into(),
793
                        name: "Literacy Course - lesson_1 - Reading".into(),
794
                        description: None,
795
                        metadata: Some(BTreeMap::from([(
796
                            "literacy_lesson".to_string(),
797
                            vec!["reading".to_string()],
798
                        )])),
799
                        lesson_material: None,
800
                        lesson_instructions: None,
801
                    },
802
                    vec![ExerciseManifest {
803
                        id: "literacy_course::lesson_1::reading::exercise".into(),
804
                        lesson_id: "literacy_course::lesson_1::reading".into(),
805
                        course_id: "literacy_course".into(),
806
                        name: "Literacy Course - lesson_1 - Reading".into(),
807
                        description: None,
808
                        exercise_type: ExerciseType::Procedural,
809
                        exercise_asset: ExerciseAsset::LiteracyAsset {
810
                            lesson_type: LiteracyLessonType::Reading,
811
                            examples: vec![
812
                                "example_0".to_string(),
813
                                "example_1".to_string(),
814
                                "simple_example_0".to_string(),
815
                                "simple_example_1".to_string(),
816
                            ],
817
                            exceptions: vec![
818
                                "exception_0".to_string(),
819
                                "exception_1".to_string(),
820
                                "simple_exception_0".to_string(),
821
                                "simple_exception_1".to_string(),
822
                            ],
823
                        },
824
                    }],
825
                ),
826
            ],
827
            updated_metadata: Some(BTreeMap::from([(
828
                "literacy_course".to_string(),
829
                vec!["true".to_string()],
830
            )])),
831
            updated_instructions: None,
832
        };
833
        assert_eq!(got, want);
834
        Ok(())
835
    }
836

837
    /// Verifies generating a literacy course with no dictation lesson.
838
    #[test]
839
    fn test_generate_manifests_no_dictation() -> Result<()> {
840
        // Create course manifest and files.
841
        let config = CourseGenerator::Literacy(LiteracyConfig {
842
            generate_dictation: false,
843
        });
844
        let course_manifest = CourseManifest {
845
            id: "literacy_course".into(),
846
            name: "Literacy Course".into(),
847
            dependencies: vec![],
848
            superseded: vec![],
849
            description: None,
850
            authors: None,
851
            metadata: None,
852
            course_material: None,
853
            course_instructions: None,
854
            generator_config: Some(config.clone()),
855
        };
856
        let temp_dir = tempfile::tempdir()?;
857
        generate_test_files(temp_dir.path(), 2, 2, 2, 2, 2)?;
858

859
        // Generate the manifests. Sort lessons and exercises by ID to have predictable outputs.
860
        let prefs = UserPreferences::default();
861
        let mut got = config.generate_manifests(temp_dir.path(), &course_manifest, &prefs)?;
862
        got.lessons.sort_by(|a, b| a.0.id.cmp(&b.0.id));
863
        for (_, exercises) in &mut got.lessons {
864
            exercises.sort_by(|a, b| a.id.cmp(&b.id));
865
        }
866

867
        // Verify the generated course.
868
        let want = GeneratedCourse {
869
            lessons: vec![
870
                (
871
                    LessonManifest {
872
                        id: "literacy_course::lesson_0::reading".into(),
873
                        dependencies: vec![],
874
                        superseded: vec![],
875
                        course_id: "literacy_course".into(),
876
                        name: "Literacy Course - lesson_0 - Reading".into(),
877
                        description: None,
878
                        metadata: Some(BTreeMap::from([(
879
                            "literacy_lesson".to_string(),
880
                            vec!["reading".to_string()],
881
                        )])),
882
                        lesson_material: None,
883
                        lesson_instructions: None,
884
                    },
885
                    vec![ExerciseManifest {
886
                        id: "literacy_course::lesson_0::reading::exercise".into(),
887
                        lesson_id: "literacy_course::lesson_0::reading".into(),
888
                        course_id: "literacy_course".into(),
889
                        name: "Literacy Course - lesson_0 - Reading".into(),
890
                        description: None,
891
                        exercise_type: ExerciseType::Procedural,
892
                        exercise_asset: ExerciseAsset::LiteracyAsset {
893
                            lesson_type: LiteracyLessonType::Reading,
894
                            examples: vec![
895
                                "example_0".to_string(),
896
                                "example_1".to_string(),
897
                                "simple_example_0".to_string(),
898
                                "simple_example_1".to_string(),
899
                            ],
900
                            exceptions: vec![
901
                                "exception_0".to_string(),
902
                                "exception_1".to_string(),
903
                                "simple_exception_0".to_string(),
904
                                "simple_exception_1".to_string(),
905
                            ],
906
                        },
907
                    }],
908
                ),
909
                (
910
                    LessonManifest {
911
                        id: "literacy_course::lesson_1::reading".into(),
912
                        dependencies: vec!["literacy_course::lesson_0::reading".into()],
913
                        superseded: vec![],
914
                        course_id: "literacy_course".into(),
915
                        name: "Literacy Course - lesson_1 - Reading".into(),
916
                        description: None,
917
                        metadata: Some(BTreeMap::from([(
918
                            "literacy_lesson".to_string(),
919
                            vec!["reading".to_string()],
920
                        )])),
921
                        lesson_material: None,
922
                        lesson_instructions: None,
923
                    },
924
                    vec![ExerciseManifest {
925
                        id: "literacy_course::lesson_1::reading::exercise".into(),
926
                        lesson_id: "literacy_course::lesson_1::reading".into(),
927
                        course_id: "literacy_course".into(),
928
                        name: "Literacy Course - lesson_1 - Reading".into(),
929
                        description: None,
930
                        exercise_type: ExerciseType::Procedural,
931
                        exercise_asset: ExerciseAsset::LiteracyAsset {
932
                            lesson_type: LiteracyLessonType::Reading,
933
                            examples: vec![
934
                                "example_0".to_string(),
935
                                "example_1".to_string(),
936
                                "simple_example_0".to_string(),
937
                                "simple_example_1".to_string(),
938
                            ],
939
                            exceptions: vec![
940
                                "exception_0".to_string(),
941
                                "exception_1".to_string(),
942
                                "simple_exception_0".to_string(),
943
                                "simple_exception_1".to_string(),
944
                            ],
945
                        },
946
                    }],
947
                ),
948
            ],
949
            updated_metadata: Some(BTreeMap::from([(
950
                "literacy_course".to_string(),
951
                vec!["true".to_string()],
952
            )])),
953
            updated_instructions: None,
954
        };
955
        assert_eq!(got, want);
956
        Ok(())
957
    }
958
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc