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

trane-project / trane / 14392837286

11 Apr 2025 12:23AM UTC coverage: 99.221% (-0.4%) from 99.655%
14392837286

Pull #340

github

web-flow
Merge 48f74d0a2 into e7a732a69
Pull Request #340: Fixes to support external applications

24 of 29 new or added lines in 3 files covered. (82.76%)

19 existing lines in 4 files now uncovered.

5478 of 5521 relevant lines covered (99.22%)

172767.94 hits per line

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

98.96
/src/course_builder/knowledge_base_builder.rs
1
//! Contains utilities to make it easier to build knowledge base courses.
2
//!
3
//! The knowledge base course format is a plain-text format that is intended to be easy to edit by
4
//! hand. This module contains utilities to make it easier to generate these files, specially for
5
//! testing purposes.
6

7
use anyhow::{Context, Result, ensure};
8
use serde::{Deserialize, Serialize};
9
use std::{
10
    collections::{BTreeMap, HashSet},
11
    fs::{self, File, create_dir_all},
12
    io::Write,
13
    path::{Path, PathBuf},
14
};
15
use ustr::Ustr;
16

17
use crate::{
18
    course_builder::AssetBuilder,
19
    course_library::COURSE_MANIFEST_FILENAME,
20
    data::{CourseManifest, course_generator::knowledge_base::*},
21
};
22

23
/// A builder to generate a knowledge base exercise and associated assets.
24
pub struct ExerciseBuilder {
25
    /// The knowledge base exercise to build.
26
    pub exercise: KnowledgeBaseExercise,
27

28
    /// The assets associated with this exercise, which include the front and back of the flashcard.
29
    pub asset_builders: Vec<AssetBuilder>,
30
}
31

32
impl ExerciseBuilder {
33
    /// Writes the files needed for this exercise to the given directory.
34
    pub fn build(&self, lesson_directory: &Path) -> Result<()> {
105✔
35
        // Build all the assets.
36
        for builder in &self.asset_builders {
273✔
37
            builder.build(lesson_directory)?;
168✔
38
        }
39

40
        // Write the exercise properties to the corresponding file.
41
        if let Some(name) = &self.exercise.name {
105✔
42
            let name_json = serde_json::to_string_pretty(name)?;
1✔
43
            let name_path = lesson_directory.join(format!(
1✔
44
                "{}{}",
1✔
45
                self.exercise.short_id, EXERCISE_NAME_SUFFIX
46
            ));
47
            let mut name_file = File::create(name_path)?;
1✔
48
            name_file.write_all(name_json.as_bytes())?;
1✔
49
        }
104✔
50
        if let Some(description) = &self.exercise.description {
105✔
51
            let description_json = serde_json::to_string_pretty(description)?;
1✔
52
            let description_path = lesson_directory.join(format!(
1✔
53
                "{}{}",
1✔
54
                self.exercise.short_id, EXERCISE_DESCRIPTION_SUFFIX
55
            ));
56
            let mut description_file = File::create(description_path)?;
1✔
57
            description_file.write_all(description_json.as_bytes())?;
1✔
58
        }
104✔
59
        if let Some(exercise_type) = &self.exercise.exercise_type {
105✔
60
            let exercise_type_json = serde_json::to_string_pretty(exercise_type)?;
1✔
61
            let exercise_type_path = lesson_directory.join(format!(
1✔
62
                "{}{}",
1✔
63
                self.exercise.short_id, EXERCISE_TYPE_SUFFIX
64
            ));
65
            let mut exercise_type_file = File::create(exercise_type_path)?;
1✔
66
            exercise_type_file.write_all(exercise_type_json.as_bytes())?;
1✔
67
        }
104✔
68
        Ok(())
105✔
69
    }
105✔
70
}
71

72
/// A builder to generate a knowledge base lesson and associated assets.
73
pub struct LessonBuilder {
74
    /// The knowledge base lesson to build.
75
    pub lesson: KnowledgeBaseLesson,
76

77
    /// The exercise builders for this lesson.
78
    pub exercises: Vec<ExerciseBuilder>,
79

80
    /// The assets associated with this lesson, which include the lesson instructions and materials.
81
    pub asset_builders: Vec<AssetBuilder>,
82
}
83

84
impl LessonBuilder {
85
    /// Writes the files needed for this lesson to the given directory.
86
    pub fn build(&self, lesson_directory: &Path) -> Result<()> {
23✔
87
        // Create the lesson directory.
23✔
88
        create_dir_all(lesson_directory)?;
23✔
89

90
        // Build all the assets.
91
        for builder in &self.asset_builders {
28✔
92
            builder.build(lesson_directory)?;
5✔
93
        }
94

95
        // Build all the exercises.
96
        for builder in &self.exercises {
128✔
97
            builder.build(lesson_directory)?;
105✔
98
        }
99

100
        // Write the lesson properties to the corresponding file.
101
        if let Some(name) = &self.lesson.name {
23✔
102
            let name_json = serde_json::to_string_pretty(name)?;
1✔
103
            let name_path = lesson_directory.join(LESSON_NAME_FILE);
1✔
104
            let mut name_file = File::create(name_path)?;
1✔
105
            name_file.write_all(name_json.as_bytes())?;
1✔
106
        }
22✔
107
        if let Some(description) = &self.lesson.description {
23✔
108
            let description_json = serde_json::to_string_pretty(description)?;
1✔
109
            let description_path = lesson_directory.join(LESSON_DESCRIPTION_FILE);
1✔
110
            let mut description_file = File::create(description_path)?;
1✔
111
            description_file.write_all(description_json.as_bytes())?;
1✔
112
        }
22✔
113
        if !self.lesson.dependencies.is_empty() {
23✔
114
            let dependencies_json = serde_json::to_string_pretty(&self.lesson.dependencies)?;
16✔
115
            let dependencies_path = lesson_directory.join(LESSON_DEPENDENCIES_FILE);
16✔
116
            let mut dependencies_file = File::create(dependencies_path)?;
16✔
117
            dependencies_file.write_all(dependencies_json.as_bytes())?;
16✔
118
        }
7✔
119
        if !self.lesson.superseded.is_empty() {
23✔
120
            let superseded_json = serde_json::to_string_pretty(&self.lesson.superseded)?;
2✔
121
            let superseded_path = lesson_directory.join(LESSON_SUPERSEDED_FILE);
2✔
122
            let mut superseded_file = File::create(superseded_path)?;
2✔
123
            superseded_file.write_all(superseded_json.as_bytes())?;
2✔
124
        }
21✔
125
        if let Some(metadata) = &self.lesson.metadata {
23✔
126
            let metadata_json = serde_json::to_string_pretty(metadata)?;
2✔
127
            let metadata_path = lesson_directory.join(LESSON_METADATA_FILE);
2✔
128
            let mut metadata_file = File::create(metadata_path)?;
2✔
129
            metadata_file.write_all(metadata_json.as_bytes())?;
2✔
130
        }
21✔
131
        Ok(())
23✔
132
    }
23✔
133
}
134

135
/// A builder to generate a knowledge base course and associated assets.
136
pub struct CourseBuilder {
137
    /// Base name of the directory on which to store this course.
138
    pub directory_name: String,
139

140
    /// The builders for the lessons in this course.
141
    pub lessons: Vec<LessonBuilder>,
142

143
    /// The assets associated with this course.
144
    pub assets: Vec<AssetBuilder>,
145

146
    /// The manifest for this course.
147
    pub manifest: CourseManifest,
148
}
149

150
impl CourseBuilder {
151
    /// Creates the directory for the course.
152
    #[cfg_attr(coverage, coverage(off))]
153
    fn create_course_directory(&self, parent_directory: &Path) -> Result<PathBuf> {
154
        let course_directory = parent_directory.join(&self.directory_name);
155
        ensure!(
156
            !course_directory.is_dir(),
157
            "course directory {} already exists",
158
            course_directory.display(),
159
        );
160
        create_dir_all(&course_directory)?;
161
        Ok(course_directory)
162
    }
163

164
    /// Writes the files needed for this course to the given directory.
165
    pub fn build(&self, parent_directory: &Path) -> Result<()> {
3✔
166
        // Create the directory and write all the assets to it.
167
        let course_directory = self.create_course_directory(parent_directory)?;
3✔
168
        for builder in &self.assets {
5✔
169
            builder.build(&course_directory)?;
2✔
170
        }
171

172
        // For each lesson in the course, create a directory with the name
173
        // `<LESSON_SHORT_ID>.lesson` and build the lesson in that directory.
174
        for builder in &self.lessons {
24✔
175
            let lesson_directory =
21✔
176
                course_directory.join(format!("{}{}", builder.lesson.short_id, LESSON_SUFFIX));
21✔
177
            fs::create_dir_all(&lesson_directory)?;
21✔
178
            builder.build(&lesson_directory)?;
21✔
179
        }
180

181
        // Write the manifest to disk.
182
        let manifest_json = serde_json::to_string_pretty(&self.manifest)? + "\n";
3✔
183
        let manifest_path = course_directory.join("course_manifest.json");
3✔
184
        let mut manifest_file = File::create(manifest_path)?;
3✔
185
        manifest_file.write_all(manifest_json.as_bytes())?;
3✔
186
        Ok(())
3✔
187
    }
3✔
188
}
189

190
/// Represents a simple knowledge base exercise which only specifies the short ID of the exercise,
191
/// and the front and (optional) back of the card, which in a lot of cases are enough to deliver full
192
/// functionality of Trane. It is meant to help course authors write simple knowledge base courses by
193
/// writing a simple configuration to a single JSON file.
194
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195
pub struct SimpleKnowledgeBaseExercise {
196
    /// The short ID of the exercise.
197
    pub short_id: String,
198

199
    /// The content of the front of the card.
200
    pub front: Vec<String>,
201

202
    /// The optional content of the back of the card. If the list is empty, no file will be created.
203
    #[serde(default)]
204
    #[serde(skip_serializing_if = "Vec::is_empty")]
205
    pub back: Vec<String>,
206
}
207

208
impl SimpleKnowledgeBaseExercise {
209
    /// Generates the exercise builder for this simple knowledge base exercise.
210
    fn generate_exercise_builder(
5✔
211
        &self,
5✔
212
        short_lesson_id: Ustr,
5✔
213
        course_id: Ustr,
5✔
214
    ) -> Result<ExerciseBuilder> {
5✔
215
        // Ensure that the short ID is not empty.
5✔
216
        ensure!(!self.short_id.is_empty(), "short ID cannot be empty");
5✔
217

218
        // Generate the asset builders for the front and back of the card.
219
        let front_file = format!("{}{}", self.short_id, EXERCISE_FRONT_SUFFIX);
4✔
220
        let back_file = if self.back.is_empty() {
4✔
221
            None
2✔
222
        } else {
223
            Some(format!("{}{}", self.short_id, EXERCISE_BACK_SUFFIX))
2✔
224
        };
225

226
        let mut asset_builders = vec![AssetBuilder {
4✔
227
            file_name: front_file.clone(),
4✔
228
            contents: self.front.join("\n"),
4✔
229
        }];
4✔
230
        if !self.back.is_empty() {
4✔
231
            asset_builders.push(AssetBuilder {
2✔
232
                file_name: back_file.clone().unwrap(),
2✔
233
                contents: self.back.join("\n"),
2✔
234
            });
2✔
235
        }
2✔
236

237
        // Generate the exercise builder.
238
        Ok(ExerciseBuilder {
4✔
239
            exercise: KnowledgeBaseExercise {
4✔
240
                short_id: self.short_id.to_string(),
4✔
241
                short_lesson_id,
4✔
242
                course_id,
4✔
243
                front_file,
4✔
244
                back_file,
4✔
245
                name: None,
4✔
246
                description: None,
4✔
247
                exercise_type: None,
4✔
248
            },
4✔
249
            asset_builders,
4✔
250
        })
4✔
251
    }
5✔
252
}
253

254
//@<simple-knowledge-base-lesson
255
/// Represents a simple knowledge base lesson which only specifies the short ID of the lesson, the
256
/// dependencies of the lesson, and a list of simple exercises. The instructions, material, and
257
/// metadata can be optionally specified as well. In a lot of cases, this is enough to deliver the
258
/// full functionality of Trane. It is meant to help course authors write simple knowledge base
259
/// courses by writing a simple configuration to a single JSON file.
260
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261
pub struct SimpleKnowledgeBaseLesson {
262
    /// The short ID of the lesson.
263
    pub short_id: Ustr,
264

265
    /// The dependencies of the lesson.
266
    #[serde(default)]
267
    pub dependencies: Vec<Ustr>,
268

269
    /// The courses or lessons that this lesson supersedes.
270
    #[serde(default)]
271
    pub superseded: Vec<Ustr>,
272

273
    /// The simple exercises in the lesson.
274
    #[serde(default)]
275
    pub exercises: Vec<SimpleKnowledgeBaseExercise>,
276

277
    /// The optional metadata for the lesson.
278
    #[serde(default)]
279
    #[serde(skip_serializing_if = "Option::is_none")]
280
    pub metadata: Option<BTreeMap<String, Vec<String>>>,
281

282
    /// A list of additional files to write in the lesson directory.
283
    #[serde(default)]
284
    #[serde(skip_serializing_if = "Vec::is_empty")]
285
    pub additional_files: Vec<AssetBuilder>,
286
}
287
//>@simple-knowledge-base-lesson
288

289
impl SimpleKnowledgeBaseLesson {
290
    /// Generates the lesson builder from this simple lesson.
291
    fn generate_lesson_builder(&self, course_id: Ustr) -> Result<LessonBuilder> {
5✔
292
        // Ensure that the lesson short ID is not empty and that the exercise short IDs are unique.
5✔
293
        ensure!(
5✔
294
            !self.short_id.is_empty(),
5✔
295
            "short ID of lesson cannot be empty"
1✔
296
        );
297
        let mut short_ids = HashSet::new();
4✔
298
        for exercise in &self.exercises {
10✔
299
            ensure!(
7✔
300
                !short_ids.contains(&exercise.short_id),
7✔
301
                "short ID {} of exercise is not unique",
1✔
302
                exercise.short_id
303
            );
304
            short_ids.insert(&exercise.short_id);
6✔
305
        }
306

307
        // Generate the exercise builders.
308
        let exercises = self
3✔
309
            .exercises
3✔
310
            .iter()
3✔
311
            .map(|exercise| exercise.generate_exercise_builder(self.short_id, course_id))
5✔
312
            .collect::<Result<Vec<_>>>()?;
3✔
313

314
        // Generate the lesson builder.
315
        let has_instructions = self
2✔
316
            .additional_files
2✔
317
            .iter()
2✔
318
            .any(|asset| asset.file_name == LESSON_INSTRUCTIONS_FILE);
2✔
319
        let has_material = self
2✔
320
            .additional_files
2✔
321
            .iter()
2✔
322
            .any(|asset| asset.file_name == LESSON_MATERIAL_FILE);
3✔
323
        let lesson_builder = LessonBuilder {
2✔
324
            lesson: KnowledgeBaseLesson {
2✔
325
                short_id: self.short_id,
2✔
326
                course_id,
2✔
327
                dependencies: self.dependencies.clone(),
2✔
328
                superseded: self.superseded.clone(),
2✔
329
                name: None,
2✔
330
                description: None,
2✔
331
                metadata: self.metadata.clone(),
2✔
332
                has_instructions,
2✔
333
                has_material,
2✔
334
            },
2✔
335
            exercises,
2✔
336
            asset_builders: self.additional_files.clone(),
2✔
337
        };
2✔
338
        Ok(lesson_builder)
2✔
339
    }
5✔
340
}
341

342
//@<simple-knowledge-base-course
343
/// Represents a simple knowledge base course which only specifies the course manifest and a list of
344
/// simple lessons. It is meant to help course authors write simple knowledge base courses by
345
/// writing a simple configuration to a single JSON file.
346
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347
pub struct SimpleKnowledgeBaseCourse {
348
    /// The manifest for this course.
349
    pub manifest: CourseManifest,
350

351
    /// The simple lessons in this course.
352
    #[serde(default)]
353
    pub lessons: Vec<SimpleKnowledgeBaseLesson>,
354
}
355
//>@simple-knowledge-base-course
356

357
impl SimpleKnowledgeBaseCourse {
358
    /// Writes the course manifests and the lesson directories with the assets and exercises to the
359
    /// given directory.
360
    pub fn build(&self, root_directory: &Path) -> Result<()> {
5✔
361
        // Ensure that the lesson short IDs are unique.
5✔
362
        let mut short_ids = HashSet::new();
5✔
363
        for lesson in &self.lessons {
11✔
364
            ensure!(
7✔
365
                !short_ids.contains(&lesson.short_id),
7✔
366
                "short ID {} of lesson is not unique",
1✔
367
                lesson.short_id
368
            );
369
            short_ids.insert(&lesson.short_id);
6✔
370
        }
371

372
        // Generate the lesson builders.
373
        let lesson_builders = self
4✔
374
            .lessons
4✔
375
            .iter()
4✔
376
            .map(|lesson| lesson.generate_lesson_builder(self.manifest.id))
5✔
377
            .collect::<Result<Vec<_>>>()?;
4✔
378

379
        // Build the lessons in the course.
380
        for lesson_builder in lesson_builders {
3✔
381
            let lesson_directory = root_directory.join(format!(
2✔
382
                "{}{}",
2✔
383
                lesson_builder.lesson.short_id, LESSON_SUFFIX
2✔
384
            ));
2✔
385

2✔
386
            // Remove the lesson directories if they already exist.
2✔
387
            if lesson_directory.exists() {
2✔
388
                fs::remove_dir_all(&lesson_directory).context(format!(
1✔
389
                    "failed to remove existing lesson directory at {}",
1✔
390
                    lesson_directory.display()
1✔
UNCOV
391
                ))?;
×
392
            }
1✔
393

394
            lesson_builder.build(&lesson_directory)?;
2✔
395
        }
396

397
        // Write the course manifest.
398
        let manifest_path = root_directory.join(COURSE_MANIFEST_FILENAME);
1✔
399
        let display = manifest_path.display();
1✔
400
        let mut manifest_file = fs::File::create(&manifest_path).context(format!(
1✔
401
            "failed to create course manifest file at {display}"
1✔
UNCOV
402
        ))?;
×
403
        manifest_file
1✔
404
            .write_all(serde_json::to_string_pretty(&self.manifest)?.as_bytes())
1✔
405
            .context(format!("failed to write course manifest file at {display}"))
1✔
406
    }
5✔
407
}
408

409
#[cfg(test)]
410
#[cfg_attr(coverage, coverage(off))]
411
mod test {
412
    use std::collections::BTreeMap;
413

414
    use anyhow::Result;
415

416
    use crate::{
417
        course_builder::knowledge_base_builder::*,
418
        data::{ExerciseType, course_generator::knowledge_base::KnowledgeBaseFile},
419
    };
420

421
    /// Creates a test lesson builder.
422
    fn test_lesson_builder() -> LessonBuilder {
423
        let exercise_builder = ExerciseBuilder {
424
            exercise: KnowledgeBaseExercise {
425
                short_id: "ex1".to_string(),
426
                short_lesson_id: "lesson1".into(),
427
                course_id: "course1".into(),
428
                front_file: "ex1.front.md".to_string(),
429
                back_file: Some("ex1.back.md".to_string()),
430
                name: Some("Exercise 1".to_string()),
431
                description: Some("Exercise 1 description".to_string()),
432
                exercise_type: Some(ExerciseType::Procedural),
433
            },
434
            asset_builders: vec![
435
                AssetBuilder {
436
                    file_name: "ex1.front.md".to_string(),
437
                    contents: "Exercise 1 front".to_string(),
438
                },
439
                AssetBuilder {
440
                    file_name: "ex1.back.md".to_string(),
441
                    contents: "Exercise 1 back".to_string(),
442
                },
443
            ],
444
        };
445
        LessonBuilder {
446
            lesson: KnowledgeBaseLesson {
447
                short_id: "lesson1".into(),
448
                course_id: "course1".into(),
449
                name: Some("Lesson 1".to_string()),
450
                description: Some("Lesson 1 description".to_string()),
451
                dependencies: vec!["lesson2".into()],
452
                superseded: vec!["lesson0".into()],
453
                metadata: Some(BTreeMap::from([(
454
                    "key".to_string(),
455
                    vec!["value".to_string()],
456
                )])),
457
                has_instructions: true,
458
                has_material: true,
459
            },
460
            exercises: vec![exercise_builder],
461
            asset_builders: vec![
462
                AssetBuilder {
463
                    file_name: LESSON_INSTRUCTIONS_FILE.to_string(),
464
                    contents: "Instructions".to_string(),
465
                },
466
                AssetBuilder {
467
                    file_name: LESSON_MATERIAL_FILE.to_string(),
468
                    contents: "Material".to_string(),
469
                },
470
            ],
471
        }
472
    }
473

474
    /// Verifies that the course builder writes the correct files to disk.
475
    #[test]
476
    fn course_builder() -> Result<()> {
477
        let temp_dir = tempfile::tempdir()?;
478
        let course_builder = CourseBuilder {
479
            directory_name: "course1".into(),
480
            manifest: CourseManifest {
481
                id: "course1".into(),
482
                name: "Course 1".into(),
483
                dependencies: vec![],
484
                superseded: vec![],
485
                description: None,
486
                authors: None,
487
                metadata: None,
488
                course_material: None,
489
                course_instructions: None,
490
                generator_config: None,
491
            },
492
            lessons: vec![test_lesson_builder()],
493
            assets: vec![
494
                AssetBuilder {
495
                    file_name: "course_instructions.md".to_string(),
496
                    contents: "Course Instructions".to_string(),
497
                },
498
                AssetBuilder {
499
                    file_name: "course_material.md".to_string(),
500
                    contents: "Course Material".to_string(),
501
                },
502
            ],
503
        };
504

505
        course_builder.build(temp_dir.path())?;
506

507
        // Verify that the exercise was built correctly.
508
        let course_dir = temp_dir.path().join("course1");
509
        let lesson_dir = course_dir.join("lesson1.lesson");
510
        assert!(lesson_dir.exists());
511
        let front_file = lesson_dir.join("ex1.front.md");
512
        assert!(front_file.exists());
513
        assert_eq!(fs::read_to_string(front_file)?, "Exercise 1 front");
514
        let back_file = lesson_dir.join("ex1.back.md");
515
        assert!(back_file.exists());
516
        assert_eq!(fs::read_to_string(back_file)?, "Exercise 1 back");
517
        let name_file = lesson_dir.join("ex1.name.json");
518
        assert!(name_file.exists());
519
        assert_eq!(KnowledgeBaseFile::open::<String>(&name_file)?, "Exercise 1",);
520
        let description_file = lesson_dir.join("ex1.description.json");
521
        assert!(description_file.exists());
522
        assert_eq!(
523
            KnowledgeBaseFile::open::<String>(&description_file)?,
524
            "Exercise 1 description",
525
        );
526
        let type_file = lesson_dir.join("ex1.type.json");
527
        assert!(type_file.exists());
528
        assert_eq!(
529
            KnowledgeBaseFile::open::<ExerciseType>(&type_file)?,
530
            ExerciseType::Procedural,
531
        );
532

533
        // Verify that the lesson was built correctly.
534
        let name_file = lesson_dir.join(LESSON_NAME_FILE);
535
        assert!(name_file.exists());
536
        assert_eq!(KnowledgeBaseFile::open::<String>(&name_file)?, "Lesson 1",);
537
        let description_file = lesson_dir.join(LESSON_DESCRIPTION_FILE);
538
        assert!(description_file.exists());
539
        assert_eq!(
540
            KnowledgeBaseFile::open::<String>(&description_file)?,
541
            "Lesson 1 description",
542
        );
543
        let dependencies_file = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
544
        assert!(lesson_dir.join(LESSON_DEPENDENCIES_FILE).exists());
545
        assert_eq!(
546
            KnowledgeBaseFile::open::<Vec<String>>(&dependencies_file)?,
547
            vec!["lesson2".to_string()],
548
        );
549
        let metadata_file = lesson_dir.join(LESSON_METADATA_FILE);
550
        assert!(metadata_file.exists());
551
        assert_eq!(
552
            KnowledgeBaseFile::open::<BTreeMap<String, Vec<String>>>(&metadata_file)?,
553
            BTreeMap::from([("key".to_string(), vec!["value".to_string()])]),
554
        );
555
        let instructions_file = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
556
        assert!(instructions_file.exists());
557
        assert_eq!(fs::read_to_string(instructions_file)?, "Instructions",);
558
        let material_file = lesson_dir.join(LESSON_MATERIAL_FILE);
559
        assert!(material_file.exists());
560
        assert_eq!(fs::read_to_string(material_file)?, "Material",);
561

562
        // Verify that the course was built correctly.
563
        assert!(course_dir.join("course_manifest.json").exists());
564
        assert_eq!(
565
            KnowledgeBaseFile::open::<CourseManifest>(&course_dir.join("course_manifest.json"))
566
                .unwrap(),
567
            course_builder.manifest,
568
        );
569
        assert!(course_dir.join("course_instructions.md").exists());
570
        assert_eq!(
571
            fs::read_to_string(course_dir.join("course_instructions.md"))?,
572
            "Course Instructions",
573
        );
574
        assert!(course_dir.join("course_material.md").exists());
575
        assert_eq!(
576
            fs::read_to_string(course_dir.join("course_material.md"))?,
577
            "Course Material",
578
        );
579

580
        Ok(())
581
    }
582

583
    /// Verifies that the simple course builder writes the correct files to disk.
584
    #[test]
585
    fn build_simple_course() -> Result<()> {
586
        // Create a simple course. The first lesson sets up the minimum required fields for a
587
        // lesson, and the second lesson sets up all optional fields.
588
        let simple_course = SimpleKnowledgeBaseCourse {
589
            manifest: CourseManifest {
590
                id: "course1".into(),
591
                name: "Course 1".into(),
592
                dependencies: vec![],
593
                superseded: vec![],
594
                description: None,
595
                authors: None,
596
                metadata: None,
597
                course_material: None,
598
                course_instructions: None,
599
                generator_config: None,
600
            },
601
            lessons: vec![
602
                SimpleKnowledgeBaseLesson {
603
                    short_id: "1".into(),
604
                    dependencies: vec![],
605
                    superseded: vec![],
606
                    exercises: vec![
607
                        SimpleKnowledgeBaseExercise {
608
                            short_id: "1".into(),
609
                            front: vec!["Lesson 1, Exercise 1 front".into()],
610
                            back: vec![],
611
                        },
612
                        SimpleKnowledgeBaseExercise {
613
                            short_id: "2".into(),
614
                            front: vec!["Lesson 1, Exercise 2 front".into()],
615
                            back: vec![],
616
                        },
617
                    ],
618
                    metadata: None,
619
                    additional_files: vec![],
620
                },
621
                SimpleKnowledgeBaseLesson {
622
                    short_id: "2".into(),
623
                    dependencies: vec!["1".into()],
624
                    superseded: vec!["0".into()],
625
                    exercises: vec![
626
                        SimpleKnowledgeBaseExercise {
627
                            short_id: "1".into(),
628
                            front: vec!["Lesson 2, Exercise 1 front".into()],
629
                            back: vec!["Lesson 2, Exercise 1 back".into()],
630
                        },
631
                        SimpleKnowledgeBaseExercise {
632
                            short_id: "2".into(),
633
                            front: vec!["Lesson 2, Exercise 2 front".into()],
634
                            back: vec!["Lesson 2, Exercise 2 back".into()],
635
                        },
636
                    ],
637
                    metadata: Some(BTreeMap::from([(
638
                        "key".to_string(),
639
                        vec!["value".to_string()],
640
                    )])),
641
                    additional_files: vec![
642
                        AssetBuilder {
643
                            file_name: "dummy.md".into(),
644
                            contents: "I'm a dummy file".into(),
645
                        },
646
                        AssetBuilder {
647
                            file_name: LESSON_INSTRUCTIONS_FILE.into(),
648
                            contents: "Lesson 2 instructions".into(),
649
                        },
650
                        AssetBuilder {
651
                            file_name: LESSON_MATERIAL_FILE.into(),
652
                            contents: "Lesson 2 material".into(),
653
                        },
654
                    ],
655
                },
656
            ],
657
        };
658

659
        // Create a temp directory and one of the lesson directories with some content to ensure
660
        // that is deleted. Then build the course and verify the contents of the output directory.
661
        let temp_dir = tempfile::tempdir()?;
662
        let dummy_dir = temp_dir.path().join("1.lesson").join("dummy");
663
        fs::create_dir_all(&dummy_dir)?;
664
        assert!(dummy_dir.exists());
665
        simple_course.build(temp_dir.path())?;
666
        assert!(!dummy_dir.exists());
667

668
        // Verify that the first lesson was built correctly.
669
        let lesson_dir = temp_dir.path().join("1.lesson");
670
        assert!(lesson_dir.exists());
671
        let front_file = lesson_dir.join("1.front.md");
672
        assert!(front_file.exists());
673
        assert_eq!(
674
            fs::read_to_string(&front_file)?,
675
            "Lesson 1, Exercise 1 front"
676
        );
677
        let front_file = lesson_dir.join("2.front.md");
678
        assert!(front_file.exists());
679
        assert_eq!(
680
            fs::read_to_string(&front_file)?,
681
            "Lesson 1, Exercise 2 front"
682
        );
683
        let dependencies_file = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
684
        assert!(!dependencies_file.exists());
685
        let superseced_file = lesson_dir.join(LESSON_SUPERSEDED_FILE);
686
        assert!(!superseced_file.exists());
687
        let instructions_file = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
688
        assert!(!instructions_file.exists());
689
        let material_file = lesson_dir.join(LESSON_MATERIAL_FILE);
690
        assert!(!material_file.exists());
691

692
        // Verify that the second lesson was built correctly.
693
        let lesson_dir = temp_dir.path().join("2.lesson");
694
        assert!(lesson_dir.exists());
695
        let front_file = lesson_dir.join("1.front.md");
696
        assert!(front_file.exists());
697
        assert_eq!(
698
            fs::read_to_string(&front_file)?,
699
            "Lesson 2, Exercise 1 front"
700
        );
701
        let back_file = lesson_dir.join("1.back.md");
702
        assert!(back_file.exists());
703
        assert_eq!(fs::read_to_string(&back_file)?, "Lesson 2, Exercise 1 back");
704
        let front_file = lesson_dir.join("2.front.md");
705
        assert!(front_file.exists());
706
        assert_eq!(
707
            fs::read_to_string(&front_file)?,
708
            "Lesson 2, Exercise 2 front"
709
        );
710
        let back_file = lesson_dir.join("2.back.md");
711
        assert!(back_file.exists());
712
        assert_eq!(fs::read_to_string(&back_file)?, "Lesson 2, Exercise 2 back");
713
        let dependencies_file = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
714
        assert!(dependencies_file.exists());
715
        assert_eq!(
716
            KnowledgeBaseFile::open::<Vec<String>>(&dependencies_file)?,
717
            vec!["1".to_string()]
718
        );
719
        let superseced_file = lesson_dir.join(LESSON_SUPERSEDED_FILE);
720
        assert!(superseced_file.exists());
721
        assert_eq!(
722
            KnowledgeBaseFile::open::<Vec<String>>(&superseced_file)?,
723
            vec!["0".to_string()]
724
        );
725
        let instructions_file = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
726
        assert!(instructions_file.exists());
727
        assert_eq!(
728
            fs::read_to_string(&instructions_file)?,
729
            "Lesson 2 instructions"
730
        );
731
        let material_file = lesson_dir.join(LESSON_MATERIAL_FILE);
732
        assert!(material_file.exists());
733
        assert_eq!(fs::read_to_string(&material_file)?, "Lesson 2 material");
734
        let metadata_file = lesson_dir.join(LESSON_METADATA_FILE);
735
        assert!(metadata_file.exists());
736
        assert_eq!(
737
            KnowledgeBaseFile::open::<BTreeMap<String, Vec<String>>>(&metadata_file)?,
738
            BTreeMap::from([("key".to_string(), vec!["value".to_string()])])
739
        );
740
        let dummy_file = lesson_dir.join("dummy.md");
741
        assert!(dummy_file.exists());
742
        assert_eq!(fs::read_to_string(&dummy_file)?, "I'm a dummy file");
743

744
        // Finally, clone the simple knowledge course to satisfy the code coverage check.
745
        assert_eq!(simple_course.clone(), simple_course);
746
        Ok(())
747
    }
748

749
    // Verifies that the simple knowledge course checks for duplicate lesson IDs.
750
    #[test]
751
    fn duplicate_short_lesson_ids() -> Result<()> {
752
        // Build a simple course with duplicate lesson IDs.
753
        let simple_course = SimpleKnowledgeBaseCourse {
754
            manifest: CourseManifest {
755
                id: "course1".into(),
756
                name: "Course 1".into(),
757
                dependencies: vec![],
758
                superseded: vec![],
759
                description: None,
760
                authors: None,
761
                metadata: None,
762
                course_material: None,
763
                course_instructions: None,
764
                generator_config: None,
765
            },
766
            lessons: vec![
767
                SimpleKnowledgeBaseLesson {
768
                    short_id: "1".into(),
769
                    dependencies: vec![],
770
                    superseded: vec![],
771
                    exercises: vec![SimpleKnowledgeBaseExercise {
772
                        short_id: "1".into(),
773
                        front: vec!["Lesson 1, Exercise 1 front".into()],
774
                        back: vec![],
775
                    }],
776
                    metadata: None,
777
                    additional_files: vec![],
778
                },
779
                SimpleKnowledgeBaseLesson {
780
                    short_id: "1".into(),
781
                    dependencies: vec![],
782
                    superseded: vec![],
783
                    exercises: vec![SimpleKnowledgeBaseExercise {
784
                        short_id: "1".into(),
785
                        front: vec!["Lesson 2, Exercise 1 front".into()],
786
                        back: vec![],
787
                    }],
788
                    metadata: None,
789
                    additional_files: vec![],
790
                },
791
            ],
792
        };
793

794
        // Verify that the course builder fails.
795
        let temp_dir = tempfile::tempdir()?;
796
        assert!(simple_course.build(temp_dir.path()).is_err());
797
        Ok(())
798
    }
799

800
    // Verifies that the simple knowledge course checks for duplicate exercise IDs.
801
    #[test]
802
    fn duplicate_short_exercise_ids() -> Result<()> {
803
        // Build a simple course with duplicate exercise IDs.
804
        let simple_course = SimpleKnowledgeBaseCourse {
805
            manifest: CourseManifest {
806
                id: "course1".into(),
807
                name: "Course 1".into(),
808
                dependencies: vec![],
809
                superseded: vec![],
810
                description: None,
811
                authors: None,
812
                metadata: None,
813
                course_material: None,
814
                course_instructions: None,
815
                generator_config: None,
816
            },
817
            lessons: vec![SimpleKnowledgeBaseLesson {
818
                short_id: "1".into(),
819
                dependencies: vec![],
820
                superseded: vec![],
821
                exercises: vec![
822
                    SimpleKnowledgeBaseExercise {
823
                        short_id: "1".into(),
824
                        front: vec!["Lesson 1, Exercise 1 front".into()],
825
                        back: vec![],
826
                    },
827
                    SimpleKnowledgeBaseExercise {
828
                        short_id: "1".into(),
829
                        front: vec!["Lesson 1, Exercise 2 front".into()],
830
                        back: vec![],
831
                    },
832
                ],
833
                metadata: None,
834
                additional_files: vec![],
835
            }],
836
        };
837

838
        // Verify that the course builder fails.
839
        let temp_dir = tempfile::tempdir()?;
840
        assert!(simple_course.build(temp_dir.path()).is_err());
841
        Ok(())
842
    }
843

844
    // Verifies that the simple knowledge course checks empty lesson IDs.
845
    #[test]
846
    fn empty_short_lesson_ids() -> Result<()> {
847
        // Build a simple course with empty lesson IDs.
848
        let simple_course = SimpleKnowledgeBaseCourse {
849
            manifest: CourseManifest {
850
                id: "course1".into(),
851
                name: "Course 1".into(),
852
                dependencies: vec![],
853
                superseded: vec![],
854
                description: None,
855
                authors: None,
856
                metadata: None,
857
                course_material: None,
858
                course_instructions: None,
859
                generator_config: None,
860
            },
861
            lessons: vec![SimpleKnowledgeBaseLesson {
862
                short_id: "".into(),
863
                dependencies: vec![],
864
                superseded: vec![],
865
                exercises: vec![SimpleKnowledgeBaseExercise {
866
                    short_id: "1".into(),
867
                    front: vec!["Lesson 1, Exercise 1 front".into()],
868
                    back: vec![],
869
                }],
870
                metadata: None,
871
                additional_files: vec![],
872
            }],
873
        };
874

875
        // Verify that the course builder fails.
876
        let temp_dir = tempfile::tempdir()?;
877
        assert!(simple_course.build(temp_dir.path()).is_err());
878
        Ok(())
879
    }
880

881
    // Verifies that the simple knowledge course checks empty exercise IDs.
882
    #[test]
883
    fn empty_short_exercise_ids() -> Result<()> {
884
        // Build a simple course with empty exercise IDs.
885
        let simple_course = SimpleKnowledgeBaseCourse {
886
            manifest: CourseManifest {
887
                id: "course1".into(),
888
                name: "Course 1".into(),
889
                dependencies: vec![],
890
                superseded: vec![],
891
                description: None,
892
                authors: None,
893
                metadata: None,
894
                course_material: None,
895
                course_instructions: None,
896
                generator_config: None,
897
            },
898
            lessons: vec![SimpleKnowledgeBaseLesson {
899
                short_id: "1".into(),
900
                dependencies: vec![],
901
                superseded: vec![],
902
                exercises: vec![SimpleKnowledgeBaseExercise {
903
                    short_id: String::new(),
904
                    front: vec!["Lesson 1, Exercise 1 front".into()],
905
                    back: vec![],
906
                }],
907
                metadata: None,
908
                additional_files: vec![],
909
            }],
910
        };
911

912
        // Verify that the course builder fails.
913
        let temp_dir = tempfile::tempdir()?;
914
        assert!(simple_course.build(temp_dir.path()).is_err());
915
        Ok(())
916
    }
917
}
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