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

trane-project / trane / 10119610613

27 Jul 2024 01:09AM UTC coverage: 99.662% (-0.3%) from 100.0%
10119610613

Pull #313

github

web-flow
Merge e8f491b26 into 5c1b2fdc8
Pull Request #313: Remove clone tests

2 of 2 new or added lines in 1 file covered. (100.0%)

22 existing lines in 8 files now uncovered.

6479 of 6501 relevant lines covered (99.66%)

87678.12 hits per line

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

99.79
/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::{ensure, Context, Result};
8
use serde::{Deserialize, Serialize};
9
use std::{
10
    collections::{BTreeMap, HashSet},
11
    fs::{self, create_dir_all, File},
12
    io::Write,
13
    path::Path,
14
};
15
use ustr::Ustr;
16

17
use crate::{
18
    course_builder::AssetBuilder,
19
    course_library::COURSE_MANIFEST_FILENAME,
20
    data::{course_generator::knowledge_base::*, CourseManifest},
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
                "{}{}",
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
        }
1✔
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
                "{}{}",
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
        }
1✔
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
                "{}{}",
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())?;
106✔
67
        }
1✔
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.
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
        }
1✔
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
        }
1✔
113
        if !self.lesson.dependencies.is_empty() {
23✔
114
            let dependencies_json = serde_json::to_string_pretty(&self.lesson.dependencies)?;
17✔
115
            let dependencies_path = lesson_directory.join(LESSON_DEPENDENCIES_FILE);
17✔
116
            let mut dependencies_file = File::create(dependencies_path)?;
17✔
117
            dependencies_file.write_all(dependencies_json.as_bytes())?;
17✔
118
        }
17✔
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
        }
2✔
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())?;
25✔
130
        }
2✔
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
    /// Writes the files needed for this course to the given directory.
152
    pub fn build(&self, parent_directory: &Path) -> Result<()> {
3✔
153
        // Verify that the directory doesn't already exist and create it.
154
        let course_directory = parent_directory.join(&self.directory_name);
3✔
155
        ensure!(
156
            !course_directory.is_dir(),
3✔
157
            "course directory {} already exists",
158
            course_directory.display(), // grcov-excl-line
159
        );
160
        create_dir_all(&course_directory)?;
3✔
161

162
        // Write all the assets.
163
        for builder in &self.assets {
5✔
164
            builder.build(&course_directory)?;
2✔
165
        }
166

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

176
        // Write the manifest to disk.
177
        let manifest_json = serde_json::to_string_pretty(&self.manifest)? + "\n";
3✔
178
        let manifest_path = course_directory.join("course_manifest.json");
3✔
179
        let mut manifest_file = File::create(manifest_path)?;
3✔
180
        manifest_file.write_all(manifest_json.as_bytes())?;
6✔
181
        Ok(())
3✔
182
    }
3✔
183
}
184

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

194
    /// The content of the front of the card.
195
    pub front: Vec<String>,
8✔
196

197
    /// The optional content of the back of the card. If the list is empty, no file will be created.
198
    #[serde(default)]
199
    #[serde(skip_serializing_if = "Vec::is_empty")]
200
    pub back: Vec<String>,
8✔
201
}
202

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

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

221
        let mut asset_builders = vec![AssetBuilder {
8✔
222
            file_name: front_file.clone(),
4✔
223
            contents: self.front.join("\n"),
4✔
224
        }];
225
        if !self.back.is_empty() {
4✔
226
            asset_builders.push(AssetBuilder {
2✔
227
                file_name: back_file.clone().unwrap(),
2✔
228
                contents: self.back.join("\n"),
2✔
229
            });
230
        }
231

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

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

260
    /// The dependencies of the lesson.
261
    #[serde(default)]
262
    pub dependencies: Vec<Ustr>,
4✔
263

264
    /// The courses or lessons that this lesson supersedes.
265
    #[serde(default)]
266
    pub superseded: Vec<Ustr>,
4✔
267

268
    /// The simple exercises in the lesson.
269
    #[serde(default)]
270
    pub exercises: Vec<SimpleKnowledgeBaseExercise>,
4✔
271

272
    /// The optional metadata for the lesson.
273
    #[serde(default)]
274
    #[serde(skip_serializing_if = "Option::is_none")]
275
    pub metadata: Option<BTreeMap<String, Vec<String>>>,
4✔
276

277
    /// A list of additional files to write in the lesson directory.
278
    #[serde(default)]
279
    #[serde(skip_serializing_if = "Vec::is_empty")]
280
    pub additional_files: Vec<AssetBuilder>,
4✔
281
}
282
//>@simple-knowledge-base-lesson
283

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

302
        // Generate the exercise builders.
303
        let exercises = self
3✔
304
            .exercises
305
            .iter()
306
            .map(|exercise| exercise.generate_exercise_builder(self.short_id, course_id))
5✔
307
            .collect::<Result<Vec<_>>>()?;
1✔
308

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

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

346
    /// The simple lessons in this course.
347
    #[serde(default)]
348
    pub lessons: Vec<SimpleKnowledgeBaseLesson>,
2✔
349
}
350
//>@simple-knowledge-base-course
351

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

367
        // Generate the lesson builders.
368
        let lesson_builders = self
4✔
369
            .lessons
370
            .iter()
371
            .map(|lesson| lesson.generate_lesson_builder(self.manifest.id))
5✔
372
            .collect::<Result<Vec<_>>>()?;
3✔
373

374
        // Build the lessons in the course.
375
        for lesson_builder in lesson_builders {
3✔
376
            let lesson_directory = root_directory.join(format!(
2✔
377
                "{}{}",
378
                lesson_builder.lesson.short_id, LESSON_SUFFIX
379
            ));
380

381
            // Remove the lesson directories if they already exist.
382
            if lesson_directory.exists() {
2✔
383
                fs::remove_dir_all(&lesson_directory).with_context(|| {
1✔
384
                    // grcov-excl-start
385
                    format!(
386
                        "failed to remove existing lesson directory at {}",
387
                        lesson_directory.display()
388
                    )
389
                    // grcov-excl-stop
390
                })?; // grcov-excl-line
391
            }
392

393
            lesson_builder.build(&lesson_directory)?;
7✔
394
        }
3✔
395

396
        // Write the course manifest.
397
        let manifest_path = root_directory.join(COURSE_MANIFEST_FILENAME);
1✔
398
        let mut manifest_file = fs::File::create(&manifest_path).with_context(|| {
1✔
399
            // grcov-excl-start
400
            format!(
401
                "failed to create course manifest file at {}",
402
                manifest_path.display()
403
            )
404
            // grcov-excl-stop
405
        })?; // grcov-excl-line
406
        manifest_file
1✔
407
            .write_all(serde_json::to_string_pretty(&self.manifest)?.as_bytes())
1✔
UNCOV
408
            .with_context(|| {
×
409
                // grcov-excl-start
410
                format!(
411
                    "failed to write course manifest file at {}",
412
                    manifest_path.display()
413
                )
414
                // grcov-excl-stop
415
            }) // grcov-excl-line
416
    }
5✔
417
}
418

419
#[cfg(test)]
420
mod test {
421
    use std::collections::BTreeMap;
422

423
    use anyhow::Result;
424

425
    use crate::{
426
        course_builder::knowledge_base_builder::*,
427
        data::{course_generator::knowledge_base::KnowledgeBaseFile, ExerciseType},
428
    };
429

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

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

514
        course_builder.build(temp_dir.path())?;
1✔
515

516
        // Verify that the exercise was built correctly.
517
        let course_dir = temp_dir.path().join("course1");
1✔
518
        let lesson_dir = course_dir.join("lesson1.lesson");
1✔
519
        assert!(lesson_dir.exists());
520
        let front_file = lesson_dir.join("ex1.front.md");
1✔
521
        assert!(front_file.exists());
522
        assert_eq!(fs::read_to_string(front_file)?, "Exercise 1 front");
1✔
523
        let back_file = lesson_dir.join("ex1.back.md");
1✔
524
        assert!(back_file.exists());
525
        assert_eq!(fs::read_to_string(back_file)?, "Exercise 1 back");
1✔
526
        let name_file = lesson_dir.join("ex1.name.json");
1✔
527
        assert!(name_file.exists());
528
        assert_eq!(KnowledgeBaseFile::open::<String>(&name_file)?, "Exercise 1",);
1✔
529
        let description_file = lesson_dir.join("ex1.description.json");
1✔
530
        assert!(description_file.exists());
531
        assert_eq!(
1✔
532
            KnowledgeBaseFile::open::<String>(&description_file)?,
1✔
533
            "Exercise 1 description",
534
        );
535
        let type_file = lesson_dir.join("ex1.type.json");
1✔
536
        assert!(type_file.exists());
537
        assert_eq!(
1✔
538
            KnowledgeBaseFile::open::<ExerciseType>(&type_file)?,
1✔
539
            ExerciseType::Procedural,
540
        );
541

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

571
        // Verify that the course was built correctly.
572
        assert!(course_dir.join("course_manifest.json").exists());
573
        assert_eq!(
1✔
574
            KnowledgeBaseFile::open::<CourseManifest>(&course_dir.join("course_manifest.json"))
1✔
575
                .unwrap(),
576
            course_builder.manifest,
577
        );
578
        assert!(course_dir.join("course_instructions.md").exists());
579
        assert_eq!(
1✔
580
            fs::read_to_string(course_dir.join("course_instructions.md"))?,
1✔
581
            "Course Instructions",
582
        );
583
        assert!(course_dir.join("course_material.md").exists());
584
        assert_eq!(
1✔
585
            fs::read_to_string(course_dir.join("course_material.md"))?,
1✔
586
            "Course Material",
587
        );
588

589
        Ok(())
1✔
590
    }
2✔
591

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

668
        // Create a temp directory and one of the lesson directories with some content to ensure
669
        // that is deleted. Then build the course and verify the contents of the output directory.
670
        let temp_dir = tempfile::tempdir()?;
1✔
671
        let dummy_dir = temp_dir.path().join("1.lesson").join("dummy");
1✔
672
        fs::create_dir_all(&dummy_dir)?;
1✔
673
        assert!(dummy_dir.exists());
674
        simple_course.build(temp_dir.path())?;
1✔
675
        assert!(!dummy_dir.exists());
676

677
        // Verify that the first lesson was built correctly.
678
        let lesson_dir = temp_dir.path().join("1.lesson");
1✔
679
        assert!(lesson_dir.exists());
680
        let front_file = lesson_dir.join("1.front.md");
1✔
681
        assert!(front_file.exists());
682
        assert_eq!(
1✔
683
            fs::read_to_string(&front_file)?,
1✔
684
            "Lesson 1, Exercise 1 front"
685
        );
686
        let front_file = lesson_dir.join("2.front.md");
1✔
687
        assert!(front_file.exists());
688
        assert_eq!(
1✔
689
            fs::read_to_string(&front_file)?,
1✔
690
            "Lesson 1, Exercise 2 front"
691
        );
692
        let dependencies_file = lesson_dir.join(LESSON_DEPENDENCIES_FILE);
1✔
693
        assert!(!dependencies_file.exists());
694
        let superseced_file = lesson_dir.join(LESSON_SUPERSEDED_FILE);
1✔
695
        assert!(!superseced_file.exists());
696
        let instructions_file = lesson_dir.join(LESSON_INSTRUCTIONS_FILE);
1✔
697
        assert!(!instructions_file.exists());
698
        let material_file = lesson_dir.join(LESSON_MATERIAL_FILE);
1✔
699
        assert!(!material_file.exists());
700

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

753
        // Finally, clone the simple knowledge course to satisfy the code coverage check.
754
        assert_eq!(simple_course.clone(), simple_course);
1✔
755
        Ok(())
1✔
756
    }
2✔
757

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

803
        // Verify that the course builder fails.
804
        let temp_dir = tempfile::tempdir()?;
1✔
805
        assert!(simple_course.build(temp_dir.path()).is_err());
806
        Ok(())
1✔
807
    }
2✔
808

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

847
        // Verify that the course builder fails.
848
        let temp_dir = tempfile::tempdir()?;
1✔
849
        assert!(simple_course.build(temp_dir.path()).is_err());
850
        Ok(())
1✔
851
    }
2✔
852

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

884
        // Verify that the course builder fails.
885
        let temp_dir = tempfile::tempdir()?;
1✔
886
        assert!(simple_course.build(temp_dir.path()).is_err());
887
        Ok(())
1✔
888
    }
2✔
889

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

921
        // Verify that the course builder fails.
922
        let temp_dir = tempfile::tempdir()?;
1✔
923
        assert!(simple_course.build(temp_dir.path()).is_err());
924
        Ok(())
1✔
925
    }
2✔
926
}
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

© 2026 Coveralls, Inc