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

trane-project / trane / 13601130494

01 Mar 2025 04:33AM UTC coverage: 99.653% (-0.01%) from 99.664%
13601130494

Pull #326

github

web-flow
Merge 0ceabbef1 into e05527640
Pull Request #326: Add storage for unit rewards

134 of 135 new or added lines in 3 files covered. (99.26%)

2 existing lines in 1 file now uncovered.

5176 of 5194 relevant lines covered (99.65%)

200948.99 hits per line

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

99.09
/src/data.rs
1
//! Defines the basic data structures used by Trane to describe courses, lessons, and exercises,
2
//! store the results of a student's attempt at mastering an exercise, the options avaialble to
3
//! control the behavior of the scheduler, among other things.
4

5
pub mod course_generator;
6
pub mod filter;
7
#[cfg_attr(coverage, coverage(off))]
8
pub mod music;
9

10
use anyhow::{bail, Result};
11
use derive_builder::Builder;
12
use serde::{Deserialize, Serialize};
13
use std::{collections::BTreeMap, path::Path};
14
use ts_rs::TS;
15
use ustr::Ustr;
16

17
use crate::data::course_generator::{
18
    knowledge_base::KnowledgeBaseConfig,
19
    literacy::{LiteracyConfig, LiteracyLessonType},
20
    music_piece::MusicPieceConfig,
21
    transcription::{TranscriptionConfig, TranscriptionLink, TranscriptionPreferences},
22
};
23

24
/// The score used by students to evaluate their mastery of a particular exercise after a trial.
25
/// More detailed descriptions of the levels are provided using the example of an exercise that
26
/// requires the student to learn a musical passage.
27
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
7✔
28
#[ts(export)]
29
pub enum MasteryScore {
30
    /// One signifies the student has barely any mastery of the exercise. For a musical passage,
31
    /// this level of mastery represents the initial attempts at hearing and reading the music, and
32
    /// figuring out the movements required to perform it.
33
    One,
34

35
    /// Two signifies the student has achieved some mastery of the exercise. For a musical passage,
36
    /// this level of mastery represents the stage at which the student knows the music, the
37
    /// required movements, and can perform the passage slowly with some mistakes.
38
    Two,
39

40
    /// Three signifies the student has achieved significant mastery of the exercise. For a musical
41
    /// passage, this level of mastery represents the stage at which the student can perform the
42
    /// material slowly with barely any mistakes, and has begun to learn it at higher tempos.
43
    Three,
44

45
    /// Four signifies the student has gained mastery of the exercise, requiring almost not
46
    /// conscious thought to complete it. For a musical passage, this level of mastery represents
47
    /// the stage at which the student can perform the material at the desired tempo with all
48
    /// elements (rhythm, dynamics, etc.) completely integrated into the performance.
49
    Four,
50

51
    /// Five signifies the student has gained total mastery of the material and can apply it in
52
    /// novel situations and come up with new variations. For exercises that test declarative
53
    /// knowledge or that do not easily lend themselves for variations (e.g., a question on some
54
    /// programming language's feature), the difference between the fourth and fifth level is just a
55
    /// matter of increased speed and accuracy. For a musical passage, this level of mastery
56
    /// represents the stage at which the student can perform the material without making mistakes.
57
    /// In addition, they can also play their own variations of the material by modifying the
58
    /// melody, harmony, dynamics, rhythm, etc., and do so effortlessly.
59
    Five,
60
}
61

62
impl MasteryScore {
63
    /// Assigns a float value to each of the values of `MasteryScore`.
64
    #[must_use]
65
    pub fn float_score(&self) -> f32 {
102,296✔
66
        match *self {
102,296✔
67
            Self::One => 1.0,
11,562✔
68
            Self::Two => 2.0,
2✔
69
            Self::Three => 3.0,
10✔
70
            Self::Four => 4.0,
4,208✔
71
            Self::Five => 5.0,
86,514✔
72
        }
73
    }
102,296✔
74
}
75

76
impl TryFrom<MasteryScore> for f32 {
77
    type Error = ();
78

79
    fn try_from(score: MasteryScore) -> Result<f32, ()> {
5✔
80
        Ok(score.float_score())
5✔
81
    }
5✔
82
}
83

84
impl TryFrom<f32> for MasteryScore {
85
    type Error = ();
86

87
    fn try_from(score: f32) -> Result<MasteryScore, ()> {
9✔
88
        if (score - 1.0_f32).abs() < f32::EPSILON {
9✔
89
            Ok(MasteryScore::One)
1✔
90
        } else if (score - 2.0_f32).abs() < f32::EPSILON {
8✔
91
            Ok(MasteryScore::Two)
1✔
92
        } else if (score - 3.0_f32).abs() < f32::EPSILON {
7✔
93
            Ok(MasteryScore::Three)
1✔
94
        } else if (score - 4.0_f32).abs() < f32::EPSILON {
6✔
95
            Ok(MasteryScore::Four)
1✔
96
        } else if (score - 5.0_f32).abs() < f32::EPSILON {
5✔
97
            Ok(MasteryScore::Five)
1✔
98
        } else {
99
            Err(())
4✔
100
        }
101
    }
9✔
102
}
103

104
//@<lp-example-4
105
/// The result of a single trial.
106
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
7✔
107
#[ts(export)]
108
pub struct ExerciseTrial {
109
    /// The score assigned to the exercise after the trial.
110
    pub score: f32,
111

112
    /// The timestamp at which the trial happened.
113
    pub timestamp: i64,
114
}
115
//>@lp-example-4
116

117
/// The reward assigned to a single exercise. Rewards are used to adjust an exercise's score based
118
/// on performance of related exercises.
NEW
119
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
×
120
pub struct UnitReward {
121
    /// The reward assigned to the exercise. The value can be negative, zero, or positive.
122
    pub reward: f32,
123

124
    /// The timestamp at which the reward was assigned.
125
    pub timestamp: i64,
126
}
127

128
/// The type of the units stored in the dependency graph.
129
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
7✔
130
#[ts(export)]
131
pub enum UnitType {
132
    /// A single task, which the student is meant to perform and assess.
133
    Exercise,
134

135
    /// A set of related exercises. There are no dependencies between the exercises in a single
136
    /// lesson, so students could see them in any order. Lessons themselves can depend on other
137
    /// lessons or courses. There is also an implicit dependency between a lesson and the course to
138
    /// which it belongs.
139
    Lesson,
140

141
    /// A set of related lessons around one or more similar topics. Courses can depend on other
142
    /// lessons or courses.
143
    Course,
144
}
145

146
impl std::fmt::Display for UnitType {
147
    /// Implements the [Display](std::fmt::Display) trait for [`UnitType`].
148
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3✔
149
        match self {
3✔
150
            Self::Exercise => "Exercise".fmt(f),
1✔
151
            Self::Lesson => "Lesson".fmt(f),
1✔
152
            Self::Course => "Course".fmt(f),
1✔
153
        }
154
    }
3✔
155
}
156

157
/// Trait to convert relative paths to absolute paths so that objects stored in memory contain the
158
/// full path to all their assets.
159
pub trait NormalizePaths
160
where
161
    Self: Sized,
162
{
163
    /// Converts all relative paths in the object to absolute paths.
164
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self>;
165
}
166

167
/// Converts a relative to an absolute path given a path and a working directory.
168
fn normalize_path(working_dir: &Path, path_str: &str) -> Result<String> {
29,490✔
169
    let path = Path::new(path_str);
29,490✔
170
    if path.is_absolute() {
29,490✔
171
        return Ok(path_str.to_string());
163✔
172
    }
29,327✔
173

29,327✔
174
    Ok(working_dir
29,327✔
175
        .join(path)
29,327✔
176
        .canonicalize()?
29,327✔
177
        .to_str()
29,326✔
178
        .unwrap_or(path_str)
29,326✔
179
        .to_string())
29,326✔
180
}
29,490✔
181

182
/// Trait to verify that the paths in the object are valid.
183
pub trait VerifyPaths
184
where
185
    Self: Sized,
186
{
187
    /// Checks that all the paths mentioned in the object exist in disk.
188
    fn verify_paths(&self, working_dir: &Path) -> Result<bool>;
189
}
190

191
/// Trait to get the metadata from a lesson or course manifest.
192
pub trait GetMetadata {
193
    /// Returns the manifest's metadata.
194
    fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>>;
195
}
196

197
/// Trait to get the unit type from a manifest.
198
pub trait GetUnitType {
199
    /// Returns the type of the unit associated with the manifest.
200
    fn get_unit_type(&self) -> UnitType;
201
}
202

203
/// An asset attached to a unit, which could be used to store instructions, or present the material
204
/// introduced by a course or lesson.
205
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
57✔
206
#[ts(export)]
207
pub enum BasicAsset {
208
    /// An asset containing the path to a markdown file.
209
    MarkdownAsset {
210
        /// The path to the markdown file.
211
        path: String,
212
    },
213

214
    /// An asset containing its content as a string.
215
    InlinedAsset {
216
        /// The content of the asset.
217
        content: String,
218
    },
219

220
    /// An asset containing its content as a unique string. Useful for generating assets that are
221
    /// replicated across many units.
222
    InlinedUniqueAsset {
223
        /// The content of the asset.
224
        #[ts(as = "String")]
225
        content: Ustr,
226
    },
227
}
228

229
impl NormalizePaths for BasicAsset {
230
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
3,631✔
231
        match &self {
3,631✔
232
            BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => {
233
                Ok(self.clone())
1✔
234
            }
235
            BasicAsset::MarkdownAsset { path } => {
3,630✔
236
                let abs_path = normalize_path(working_dir, path)?;
3,630✔
237
                Ok(BasicAsset::MarkdownAsset { path: abs_path })
3,630✔
238
            }
239
        }
240
    }
3,631✔
241
}
242

243
impl VerifyPaths for BasicAsset {
244
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
3,643✔
245
        match &self {
3,643✔
246
            BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => Ok(true),
1✔
247
            BasicAsset::MarkdownAsset { path } => {
3,642✔
248
                let abs_path = working_dir.join(Path::new(path));
3,642✔
249
                Ok(abs_path.exists())
3,642✔
250
            }
251
        }
252
    }
3,643✔
253
}
254

255
//@<course-generator
256
/// A configuration used for generating special types of courses on the fly.
257
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
19✔
258
#[ts(export)]
259
pub enum CourseGenerator {
260
    /// The configuration for generating a knowledge base course. Currently, there are no
261
    /// configuration options, but the struct was added to implement the [GenerateManifests] trait
262
    /// and for future extensibility.
263
    KnowledgeBase(KnowledgeBaseConfig),
264

265
    /// The configuration for generating a literacy course.
266
    Literacy(LiteracyConfig),
267

268
    /// The configuration for generating a music piece course.
269
    MusicPiece(MusicPieceConfig),
270

271
    /// The configuration for generating a transcription course.
272
    Transcription(TranscriptionConfig),
273
}
274
//>@course-generator
275

276
/// A struct holding the results from running a course generator.
277
#[derive(Debug, PartialEq)]
278
pub struct GeneratedCourse {
279
    /// The lessons and exercise manifests generated for the course.
280
    pub lessons: Vec<(LessonManifest, Vec<ExerciseManifest>)>,
281

282
    /// Updated course metadata. If None, the existing metadata is used.
283
    pub updated_metadata: Option<BTreeMap<String, Vec<String>>>,
284

285
    /// Updated course instructions. If None, the existing instructions are used.
286
    pub updated_instructions: Option<BasicAsset>,
287
}
288

289
/// The trait to return all the generated lesson and exercise manifests for a course.
290
pub trait GenerateManifests {
291
    /// Returns all the generated lesson and exercise manifests for a course.
292
    fn generate_manifests(
293
        &self,
294
        course_root: &Path,
295
        course_manifest: &CourseManifest,
296
        preferences: &UserPreferences,
297
    ) -> Result<GeneratedCourse>;
298
}
299

300
impl GenerateManifests for CourseGenerator {
301
    fn generate_manifests(
23✔
302
        &self,
23✔
303
        course_root: &Path,
23✔
304
        course_manifest: &CourseManifest,
23✔
305
        preferences: &UserPreferences,
23✔
306
    ) -> Result<GeneratedCourse> {
23✔
307
        match self {
23✔
308
            CourseGenerator::KnowledgeBase(config) => {
2✔
309
                config.generate_manifests(course_root, course_manifest, preferences)
2✔
310
            }
311
            CourseGenerator::Literacy(config) => {
2✔
312
                config.generate_manifests(course_root, course_manifest, preferences)
2✔
313
            }
314
            CourseGenerator::MusicPiece(config) => {
5✔
315
                config.generate_manifests(course_root, course_manifest, preferences)
5✔
316
            }
317
            CourseGenerator::Transcription(config) => {
14✔
318
                config.generate_manifests(course_root, course_manifest, preferences)
14✔
319
            }
320
        }
321
    }
23✔
322
}
323

324
/// A manifest describing the contents of a course.
325
#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
7✔
326
#[ts(export)]
327
pub struct CourseManifest {
328
    /// The ID assigned to this course.
329
    ///
330
    /// For example, `music::instrument::guitar::basic_jazz_chords`.
331
    #[builder(setter(into))]
332
    #[ts(as = "String")]
333
    pub id: Ustr,
334

335
    /// The name of the course to be presented to the user.
336
    ///
337
    /// For example, "Basic Jazz Chords on Guitar".
338
    #[builder(default)]
339
    #[serde(default)]
340
    pub name: String,
341

342
    /// The IDs of all dependencies of this course.
343
    #[builder(default)]
344
    #[serde(default)]
345
    #[ts(as = "Vec<String>")]
346
    pub dependencies: Vec<Ustr>,
347

348
    /// The IDs of the courses or lessons that this course supersedes. If this course is mastered,
349
    /// then exercises from the superseded courses or lessons will no longer be shown to the
350
    /// student.
351
    #[builder(default)]
352
    #[serde(default)]
353
    #[ts(as = "Vec<String>")]
354
    pub superseded: Vec<Ustr>,
355

356
    /// An optional description of the course.
357
    #[builder(default)]
358
    #[serde(default)]
359
    #[serde(skip_serializing_if = "Option::is_none")]
360
    pub description: Option<String>,
361

362
    /// An optional list of the course's authors.
363
    #[builder(default)]
364
    #[serde(default)]
365
    #[serde(skip_serializing_if = "Option::is_none")]
366
    pub authors: Option<Vec<String>>,
367

368
    //@<lp-example-5
369
    //// A mapping of String keys to a list of String values. For example, ("genre", ["jazz"]) could
370
    /// be attached to a course named "Basic Jazz Chords on Guitar".
371
    ///
372
    /// The purpose of this metadata is to allow students to focus on more specific material during
373
    /// a study session which does not belong to a single lesson or course. For example, a student
374
    /// might want to only focus on guitar scales or ear training.
375
    #[builder(default)]
376
    #[serde(default)]
377
    #[serde(skip_serializing_if = "Option::is_none")]
378
    pub metadata: Option<BTreeMap<String, Vec<String>>>,
379

380
    //>@lp-example-5
381
    /// An optional asset, which presents the material covered in the course.
382
    #[builder(default)]
383
    #[serde(default)]
384
    #[serde(skip_serializing_if = "Option::is_none")]
385
    pub course_material: Option<BasicAsset>,
386

387
    /// An optional asset, which presents instructions common to all exercises in the course.
388
    #[builder(default)]
389
    #[serde(default)]
390
    #[serde(skip_serializing_if = "Option::is_none")]
391
    pub course_instructions: Option<BasicAsset>,
392

393
    /// An optional configuration to generate material for this course. Generated courses allow
394
    /// easier creation of courses for specific purposes without requiring the manual creation of
395
    /// all the files a normal course would need.
396
    #[builder(default)]
397
    #[serde(default)]
398
    #[serde(skip_serializing_if = "Option::is_none")]
399
    pub generator_config: Option<CourseGenerator>,
400
}
401

402
impl NormalizePaths for CourseManifest {
403
    fn normalize_paths(&self, working_directory: &Path) -> Result<Self> {
478✔
404
        let mut clone = self.clone();
478✔
405
        match &self.course_instructions {
478✔
406
            None => (),
19✔
407
            Some(asset) => {
459✔
408
                clone.course_instructions = Some(asset.normalize_paths(working_directory)?);
459✔
409
            }
410
        }
411
        match &self.course_material {
478✔
412
            None => (),
19✔
413
            Some(asset) => clone.course_material = Some(asset.normalize_paths(working_directory)?),
459✔
414
        }
415
        Ok(clone)
478✔
416
    }
478✔
417
}
418

419
impl VerifyPaths for CourseManifest {
420
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
479✔
421
        // The paths mentioned in the instructions and material must both exist.
422
        let instructions_exist = match &self.course_instructions {
479✔
423
            None => true,
18✔
424
            Some(asset) => asset.verify_paths(working_dir)?,
461✔
425
        };
426
        let material_exists = match &self.course_material {
479✔
427
            None => true,
18✔
428
            Some(asset) => asset.verify_paths(working_dir)?,
461✔
429
        };
430
        Ok(instructions_exist && material_exists)
479✔
431
    }
479✔
432
}
433

434
impl GetMetadata for CourseManifest {
435
    fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>> {
24,492✔
436
        self.metadata.as_ref()
24,492✔
437
    }
24,492✔
438
}
439

440
impl GetUnitType for CourseManifest {
441
    fn get_unit_type(&self) -> UnitType {
1✔
442
        UnitType::Course
1✔
443
    }
1✔
444
}
445

446
/// A manifest describing the contents of a lesson.
447
#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
10,511✔
448
#[ts(export)]
449
pub struct LessonManifest {
450
    /// The ID assigned to this lesson.
451
    ///
452
    /// For example, `music::instrument::guitar::basic_jazz_chords::major_chords`.
453
    #[builder(setter(into))]
454
    #[ts(as = "String")]
455
    pub id: Ustr,
456

457
    /// The IDs of all dependencies of this lesson.
458
    #[builder(default)]
459
    #[serde(default)]
460
    #[ts(as = "Vec<String>")]
461
    pub dependencies: Vec<Ustr>,
462

463
    ///The IDs of the courses or lessons that this lesson supersedes. If this lesson is mastered,
464
    /// then exercises from the superseded courses or lessons will no longer be shown to the
465
    /// student.
466
    #[builder(default)]
467
    #[serde(default)]
468
    #[ts(as = "Vec<String>")]
469
    pub superseded: Vec<Ustr>,
470

471
    /// The ID of the course to which the lesson belongs.
472
    #[builder(setter(into))]
473
    #[ts(as = "String")]
474
    pub course_id: Ustr,
475

476
    /// The name of the lesson to be presented to the user.
477
    ///
478
    /// For example, "Basic Jazz Major Chords".
479
    #[builder(default)]
480
    #[serde(default)]
481
    pub name: String,
482

483
    /// An optional description of the lesson.
484
    #[builder(default)]
485
    #[serde(default)]
486
    #[serde(skip_serializing_if = "Option::is_none")]
487
    pub description: Option<String>,
488

489
    //// A mapping of String keys to a list of String values. For example, ("key", ["C"]) could
490
    /// be attached to a lesson named "C Major Scale". The purpose is the same as the metadata
491
    /// stored in the course manifest but allows finer control over which lessons are selected.
492
    #[builder(default)]
493
    #[serde(default)]
494
    #[serde(skip_serializing_if = "Option::is_none")]
495
    pub metadata: Option<BTreeMap<String, Vec<String>>>,
496

497
    /// An optional asset, which presents the material covered in the lesson.
498
    #[builder(default)]
499
    #[serde(default)]
500
    #[serde(skip_serializing_if = "Option::is_none")]
501
    pub lesson_material: Option<BasicAsset>,
502

503
    /// An optional asset, which presents instructions common to all exercises in the lesson.
504
    #[builder(default)]
505
    #[serde(default)]
506
    #[serde(skip_serializing_if = "Option::is_none")]
507
    pub lesson_instructions: Option<BasicAsset>,
508
}
509

510
impl NormalizePaths for LessonManifest {
511
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
1,356✔
512
        let mut clone = self.clone();
1,356✔
513
        if let Some(asset) = &self.lesson_instructions {
1,356✔
514
            clone.lesson_instructions = Some(asset.normalize_paths(working_dir)?);
1,356✔
UNCOV
515
        }
×
516
        if let Some(asset) = &self.lesson_material {
1,356✔
517
            clone.lesson_material = Some(asset.normalize_paths(working_dir)?);
1,356✔
UNCOV
518
        }
×
519
        Ok(clone)
1,356✔
520
    }
1,356✔
521
}
522

523
impl VerifyPaths for LessonManifest {
524
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
1,361✔
525
        // The paths mentioned in the instructions and material must both exist.
526
        let instruction_exists = match &self.lesson_instructions {
1,361✔
527
            None => true,
1✔
528
            Some(asset) => asset.verify_paths(working_dir)?,
1,360✔
529
        };
530
        let material_exists = match &self.lesson_material {
1,361✔
531
            None => true,
1✔
532
            Some(asset) => asset.verify_paths(working_dir)?,
1,360✔
533
        };
534
        Ok(instruction_exists && material_exists)
1,361✔
535
    }
1,361✔
536
}
537

538
impl GetMetadata for LessonManifest {
539
    fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>> {
17,305✔
540
        self.metadata.as_ref()
17,305✔
541
    }
17,305✔
542
}
543

544
impl GetUnitType for LessonManifest {
545
    fn get_unit_type(&self) -> UnitType {
1✔
546
        UnitType::Lesson
1✔
547
    }
1✔
548
}
549

550
/// The type of knowledge tested by an exercise.
551
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
19✔
552
#[ts(export)]
553
pub enum ExerciseType {
554
    /// Represents an exercise that tests mastery of factual knowledge. For example, an exercise
555
    /// asking students to name the notes in a D Major chord.
556
    Declarative,
557

558
    /// Represents an exercises that requires more complex actions to be performed. For example, an
559
    /// exercise asking students to play a D Major chords in a piano.
560
    #[default]
561
    Procedural,
562
}
563

564
/// The asset storing the material of a particular exercise.
565
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
19✔
566
#[ts(export)]
567
pub enum ExerciseAsset {
568
    /// A basic asset storing the material of the exercise.
569
    BasicAsset(BasicAsset),
570

571
    /// An asset representing a flashcard with a front and back each stored in a markdown file. The
572
    /// first file stores the front (question) of the flashcard while the second file stores the
573
    /// back (answer).
574
    FlashcardAsset {
575
        /// The path to the file containing the front of the flashcard.
576
        front_path: String,
577

578
        /// The path to the file containing the back of the flashcard. This path is optional,
579
        /// because a flashcard is not required to provide an answer. For example, the exercise is
580
        /// open-ended, or it is referring to an external resource which contains the exercise and
581
        /// possibly the answer.
582
        #[serde(default)]
583
        #[serde(skip_serializing_if = "Option::is_none")]
584
        back_path: Option<String>,
585
    },
586

587
    /// An asset representing a literacy exercise.
588
    LiteracyAsset {
589
        /// The type of the lesson.
590
        lesson_type: LiteracyLessonType,
591

592
        /// The examples to use in the lesson's exercise.
593
        #[serde(default)]
594
        examples: Vec<String>,
595

596
        /// The exceptions to the examples to use in the lesson's exercise.
597
        #[serde(default)]
598
        exceptions: Vec<String>,
599
    },
600

601
    /// An asset which stores a link to a SoundSlice.
602
    SoundSliceAsset {
603
        /// The link to the SoundSlice asset.
604
        link: String,
605

606
        /// An optional description of the exercise tied to this asset. For example, "Play this
607
        /// slice in the key of D Major" or "Practice measures 1 through 4". A missing description
608
        /// implies the entire slice should be practiced as is.
609
        #[serde(default)]
610
        #[serde(skip_serializing_if = "Option::is_none")]
611
        description: Option<String>,
612

613
        /// An optional path to a MusicXML file containing the sheet music for the exercise.
614
        #[serde(default)]
615
        #[serde(skip_serializing_if = "Option::is_none")]
616
        backup: Option<String>,
617
    },
618

619
    /// A transcription asset, containing an exercise's content and an optional external link to the
620
    /// audio for the exercise.
621
    TranscriptionAsset {
622
        /// The content of the exercise.
623
        #[serde(default)]
624
        content: String,
625

626
        /// An optional link to the audio for the exercise.
627
        #[serde(default)]
628
        #[serde(skip_serializing_if = "Option::is_none")]
629
        external_link: Option<TranscriptionLink>,
630
    },
631
}
632

633
impl NormalizePaths for ExerciseAsset {
634
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
13,491✔
635
        match &self {
13,491✔
636
            ExerciseAsset::BasicAsset(asset) => Ok(ExerciseAsset::BasicAsset(
1✔
637
                asset.normalize_paths(working_dir)?,
1✔
638
            )),
639
            ExerciseAsset::FlashcardAsset {
640
                front_path,
12,948✔
641
                back_path,
12,948✔
642
            } => {
643
                let abs_front_path = normalize_path(working_dir, front_path)?;
12,948✔
644
                let abs_back_path = if let Some(back_path) = back_path {
12,948✔
645
                    Some(normalize_path(working_dir, back_path)?)
12,908✔
646
                } else {
647
                    None
40✔
648
                };
649
                Ok(ExerciseAsset::FlashcardAsset {
12,948✔
650
                    front_path: abs_front_path,
12,948✔
651
                    back_path: abs_back_path,
12,948✔
652
                })
12,948✔
653
            }
654
            ExerciseAsset::LiteracyAsset { .. } | ExerciseAsset::TranscriptionAsset { .. } => {
655
                Ok(self.clone())
496✔
656
            }
657
            ExerciseAsset::SoundSliceAsset {
658
                link,
46✔
659
                description,
46✔
660
                backup,
46✔
661
            } => match backup {
46✔
662
                None => Ok(self.clone()),
45✔
663
                Some(path) => {
1✔
664
                    let abs_path = normalize_path(working_dir, path)?;
1✔
665
                    Ok(ExerciseAsset::SoundSliceAsset {
1✔
666
                        link: link.clone(),
1✔
667
                        description: description.clone(),
1✔
668
                        backup: Some(abs_path),
1✔
669
                    })
1✔
670
                }
671
            },
672
        }
673
    }
13,491✔
674
}
675

676
impl VerifyPaths for ExerciseAsset {
677
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
12,894✔
678
        match &self {
12,894✔
679
            ExerciseAsset::BasicAsset(asset) => asset.verify_paths(working_dir),
1✔
680
            ExerciseAsset::FlashcardAsset {
681
                front_path,
12,890✔
682
                back_path,
12,890✔
683
            } => {
12,890✔
684
                let front_abs_path = working_dir.join(Path::new(front_path));
12,890✔
685
                if let Some(back_path) = back_path {
12,890✔
686
                    // The paths to the front and back of the flashcard must both exist.
687
                    let back_abs_path = working_dir.join(Path::new(back_path));
12,889✔
688
                    Ok(front_abs_path.exists() && back_abs_path.exists())
12,889✔
689
                } else {
690
                    // If the back of the flashcard is missing, then the front must exist.
691
                    Ok(front_abs_path.exists())
1✔
692
                }
693
            }
694
            ExerciseAsset::LiteracyAsset { .. } | ExerciseAsset::TranscriptionAsset { .. } => {
695
                Ok(true)
1✔
696
            }
697
            ExerciseAsset::SoundSliceAsset { backup, .. } => match backup {
2✔
698
                None => Ok(true),
1✔
699
                Some(path) => {
1✔
700
                    // The backup path must exist.
1✔
701
                    let abs_path = working_dir.join(Path::new(path));
1✔
702
                    Ok(abs_path.exists())
1✔
703
                }
704
            },
705
        }
706
    }
12,894✔
707
}
708

709
/// Manifest describing a single exercise.
710
#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
46,840✔
711
#[ts(export)]
712
pub struct ExerciseManifest {
713
    /// The ID assigned to this exercise.
714
    ///
715
    /// For example, `music::instrument::guitar::basic_jazz_chords::major_chords::exercise_1`.
716
    #[builder(setter(into))]
717
    #[ts(as = "String")]
718
    pub id: Ustr,
719

720
    /// The ID of the lesson to which this exercise belongs.
721
    #[builder(setter(into))]
722
    #[ts(as = "String")]
723
    pub lesson_id: Ustr,
724

725
    /// The ID of the course to which this exercise belongs.
726
    #[builder(setter(into))]
727
    #[ts(as = "String")]
728
    pub course_id: Ustr,
729

730
    /// The name of the exercise to be presented to the user.
731
    ///
732
    /// For example, "Exercise 1".
733
    #[builder(default)]
734
    #[serde(default)]
735
    pub name: String,
736

737
    /// An optional description of the exercise.
738
    #[builder(default)]
739
    #[serde(default)]
740
    #[serde(skip_serializing_if = "Option::is_none")]
741
    pub description: Option<String>,
742

743
    /// The type of knowledge the exercise tests.
744
    #[builder(default)]
745
    #[serde(default)]
746
    pub exercise_type: ExerciseType,
747

748
    /// The asset containing the exercise itself.
749
    pub exercise_asset: ExerciseAsset,
750
}
751

752
impl NormalizePaths for ExerciseManifest {
753
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
13,489✔
754
        let mut clone = self.clone();
13,489✔
755
        clone.exercise_asset = clone.exercise_asset.normalize_paths(working_dir)?;
13,489✔
756
        Ok(clone)
13,489✔
757
    }
13,489✔
758
}
759

760
impl VerifyPaths for ExerciseManifest {
761
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
12,889✔
762
        self.exercise_asset.verify_paths(working_dir)
12,889✔
763
    }
12,889✔
764
}
765

766
impl GetUnitType for ExerciseManifest {
767
    fn get_unit_type(&self) -> UnitType {
1✔
768
        UnitType::Exercise
1✔
769
    }
1✔
770
}
771

772
/// Options to compute the passing score for a unit.
773
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
19✔
774
#[ts(export)]
775
pub enum PassingScoreOptions {
776
    /// The passing score will be a fixed value. A unit will be considered mastered if the average
777
    /// score of all its exercises is greater than or equal to this value.
778
    ConstantScore(f32),
779

780
    /// The score will start at a fixed value and increase by a fixed amount based on the depth of
781
    /// the unit relative to the starting unit. This is useful for allowing users to make faster
782
    /// progress at the beginning, so to avoid boredom. Once enough of the graph has been mastered,
783
    /// the passing score will settle to a fixed value.
784
    IncreasingScore {
785
        /// The initial score. The units at the starting depth will use this value as their passing
786
        /// score.
787
        starting_score: f32,
788

789
        /// The amount by which the score will increase for each additional depth. For example, if
790
        /// the unit is at depth 2, then the passing score will increase by `step_size * 2`.
791
        step_size: f32,
792

793
        /// The maximum number of steps that increase the passing score. Units that are deeper than
794
        /// this will have a passing score of `starting_score + step_size * max_steps`.
795
        max_steps: usize,
796
    },
797
}
798

799
impl Default for PassingScoreOptions {
800
    fn default() -> Self {
94✔
801
        PassingScoreOptions::IncreasingScore {
94✔
802
            starting_score: 3.50,
94✔
803
            step_size: 0.01,
94✔
804
            max_steps: 25,
94✔
805
        }
94✔
806
    }
94✔
807
}
808

809
impl PassingScoreOptions {
810
    /// Computes the passing score for a unit at the given depth.
811
    #[must_use]
812
    pub fn compute_score(&self, depth: usize) -> f32 {
2,529,074✔
813
        match self {
2,529,074✔
814
            PassingScoreOptions::ConstantScore(score) => score.min(5.0),
3✔
815
            PassingScoreOptions::IncreasingScore {
816
                starting_score,
2,529,071✔
817
                step_size,
2,529,071✔
818
                max_steps,
2,529,071✔
819
            } => {
2,529,071✔
820
                let steps = depth.min(*max_steps);
2,529,071✔
821
                (starting_score + step_size * steps as f32).min(5.0)
2,529,071✔
822
            }
823
        }
824
    }
2,529,074✔
825

826
    /// Verifies that the options are valid.
827
    pub fn verify(&self) -> Result<()> {
7✔
828
        match self {
7✔
829
            PassingScoreOptions::ConstantScore(score) => {
3✔
830
                if *score < 0.0 || *score > 5.0 {
3✔
831
                    bail!("Invalid score: {}", score);
2✔
832
                }
1✔
833
                Ok(())
1✔
834
            }
835
            PassingScoreOptions::IncreasingScore {
836
                starting_score,
4✔
837
                step_size,
4✔
838
                ..
4✔
839
            } => {
4✔
840
                if *starting_score < 0.0 || *starting_score > 5.0 {
4✔
841
                    bail!("Invalid starting score: {}", starting_score);
2✔
842
                }
2✔
843
                if *step_size < 0.0 {
2✔
844
                    bail!("Invalid step size: {}", step_size);
1✔
845
                }
1✔
846
                Ok(())
1✔
847
            }
848
        }
849
    }
7✔
850
}
851

852
/// A mastery window consists a range of scores and the percentage of the total exercises in the
853
/// batch returned by the scheduler that will fall within that range.
854
///
855
/// Mastery windows are used by the scheduler to control the amount of exercises for a given range
856
/// of difficulty given to the student to try to keep an optimal balance. For example, exercises
857
/// that are already fully mastered should not be shown very often lest the student becomes bored.
858
/// Very difficult exercises should not be shown too often either lest the student becomes
859
/// frustrated.
860
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
23✔
861
#[ts(export)]
862
pub struct MasteryWindow {
863
    /// The percentage of the exercises in each batch returned by the scheduler whose scores should
864
    /// fall within this window.
865
    pub percentage: f32,
866

867
    /// The range of scores which fall on this window. Scores whose values are in the range
868
    /// `[range.0, range.1)` fall within this window. If `range.1` is equal to 5.0 (the float
869
    /// representation of the maximum possible score), then the range becomes inclusive.
870
    pub range: (f32, f32),
871
}
872

873
impl MasteryWindow {
874
    /// Returns whether the given score falls within this window.
875
    #[must_use]
876
    pub fn in_window(&self, score: f32) -> bool {
13,037,260✔
877
        // Handle the special case of the window containing the maximum score. Scores greater than
13,037,260✔
878
        // 5.0 are allowed because float comparison is not exact.
13,037,260✔
879
        if self.range.1 >= 5.0 && score >= 5.0 {
13,037,260✔
880
            return true;
2,517,563✔
881
        }
10,519,697✔
882

10,519,697✔
883
        // Return true if the score falls within the range `[range.0, range.1)`.
10,519,697✔
884
        self.range.0 <= score && score < self.range.1
10,519,697✔
885
    }
13,037,260✔
886
}
887

888
/// Options to control how the scheduler selects exercises.
889
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
7✔
890
#[ts(export)]
891
pub struct SchedulerOptions {
892
    /// The maximum number of candidates to return each time the scheduler is called.
893
    pub batch_size: usize,
894

895
    /// The options of the new mastery window. That is, the window of exercises that have not
896
    /// received a score so far.
897
    pub new_window_opts: MasteryWindow,
898

899
    /// The options of the target mastery window. That is, the window of exercises that lie outside
900
    /// the user's current abilities.
901
    pub target_window_opts: MasteryWindow,
902

903
    /// The options of the current mastery window. That is, the window of exercises that lie
904
    /// slightly outside the user's current abilities.
905
    pub current_window_opts: MasteryWindow,
906

907
    /// The options of the easy mastery window. That is, the window of exercises that lie well
908
    /// within the user's current abilities.
909
    pub easy_window_opts: MasteryWindow,
910

911
    /// The options for the mastered mastery window. That is, the window of exercises that the user
912
    /// has properly mastered.
913
    pub mastered_window_opts: MasteryWindow,
914

915
    /// The minimum average score of a unit required to move on to its dependents.
916
    pub passing_score: PassingScoreOptions,
917

918
    /// The minimum score required to supersede a unit. If unit A is superseded by B, then the
919
    /// exercises from unit A will not be shown once the score of unit B is greater than or equal to
920
    /// this value.
921
    pub superseding_score: f32,
922

923
    /// The number of trials to retrieve from the practice stats to compute an exercise's score.
924
    pub num_trials: usize,
925
}
926

927
impl SchedulerOptions {
928
    #[must_use]
929
    fn float_equals(f1: f32, f2: f32) -> bool {
532✔
930
        (f1 - f2).abs() < f32::EPSILON
532✔
931
    }
532✔
932

933
    /// Verifies that the scheduler options are valid.
934
    pub fn verify(&self) -> Result<()> {
80✔
935
        // The batch size must be greater than 0.
80✔
936
        if self.batch_size == 0 {
80✔
937
            bail!("invalid scheduler options: batch_size must be greater than 0");
1✔
938
        }
79✔
939

79✔
940
        // The sum of the percentages of the mastery windows must be 1.0.
79✔
941
        if !Self::float_equals(
79✔
942
            self.mastered_window_opts.percentage
79✔
943
                + self.easy_window_opts.percentage
79✔
944
                + self.current_window_opts.percentage
79✔
945
                + self.target_window_opts.percentage
79✔
946
                + self.new_window_opts.percentage,
79✔
947
            1.0,
79✔
948
        ) {
79✔
949
            bail!(
1✔
950
                "invalid scheduler options: the sum of the percentages of the mastery windows \
1✔
951
                must be 1.0"
1✔
952
            );
1✔
953
        }
78✔
954

78✔
955
        // The new window's range must start at 0.0.
78✔
956
        if !Self::float_equals(self.new_window_opts.range.0, 0.0) {
78✔
957
            bail!("invalid scheduler options: the new window's range must start at 0.0");
1✔
958
        }
77✔
959

77✔
960
        // The mastered window's range must end at 5.0.
77✔
961
        if !Self::float_equals(self.mastered_window_opts.range.1, 5.0) {
77✔
962
            bail!("invalid scheduler options: the mastered window's range must end at 5.0");
1✔
963
        }
76✔
964

76✔
965
        // There must be no gaps in the mastery windows.
76✔
966
        if !Self::float_equals(
76✔
967
            self.new_window_opts.range.1,
76✔
968
            self.target_window_opts.range.0,
76✔
969
        ) || !Self::float_equals(
76✔
970
            self.target_window_opts.range.1,
75✔
971
            self.current_window_opts.range.0,
75✔
972
        ) || !Self::float_equals(
75✔
973
            self.current_window_opts.range.1,
74✔
974
            self.easy_window_opts.range.0,
74✔
975
        ) || !Self::float_equals(
74✔
976
            self.easy_window_opts.range.1,
73✔
977
            self.mastered_window_opts.range.0,
73✔
978
        ) {
73✔
979
            bail!("invalid scheduler options: there must be no gaps in the mastery windows");
4✔
980
        }
72✔
981

72✔
982
        Ok(())
72✔
983
    }
80✔
984
}
985

986
impl Default for SchedulerOptions {
987
    /// Returns the default scheduler options.
988
    fn default() -> Self {
92✔
989
        // Consider an exercise to be new if its score is less than 0.1. In reality, all such
92✔
990
        // exercises will have a score of 0.0, but we add a small margin to make this window act
92✔
991
        // like all the others.
92✔
992
        SchedulerOptions {
92✔
993
            batch_size: 50,
92✔
994
            new_window_opts: MasteryWindow {
92✔
995
                percentage: 0.3,
92✔
996
                range: (0.0, 0.1),
92✔
997
            },
92✔
998
            target_window_opts: MasteryWindow {
92✔
999
                percentage: 0.2,
92✔
1000
                range: (0.1, 2.5),
92✔
1001
            },
92✔
1002
            current_window_opts: MasteryWindow {
92✔
1003
                percentage: 0.2,
92✔
1004
                range: (2.5, 3.75),
92✔
1005
            },
92✔
1006
            easy_window_opts: MasteryWindow {
92✔
1007
                percentage: 0.2,
92✔
1008
                range: (3.75, 4.5),
92✔
1009
            },
92✔
1010
            mastered_window_opts: MasteryWindow {
92✔
1011
                percentage: 0.1,
92✔
1012
                range: (4.5, 5.0),
92✔
1013
            },
92✔
1014
            passing_score: PassingScoreOptions::default(),
92✔
1015
            superseding_score: 3.75,
92✔
1016
            num_trials: 10,
92✔
1017
        }
92✔
1018
    }
92✔
1019
}
1020

1021
/// Represents the scheduler's options that can be customized by the user.
1022
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
19✔
1023
#[ts(export)]
1024
pub struct SchedulerPreferences {
1025
    /// The maximum number of candidates to return each time the scheduler is called.
1026
    #[serde(default)]
1027
    pub batch_size: Option<usize>,
1028
}
1029

1030
/// Represents a repository containing Trane courses.
1031
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
7✔
1032
#[ts(export)]
1033
pub struct RepositoryMetadata {
1034
    /// The ID of the repository, which is also used to name the directory.
1035
    pub id: String,
1036

1037
    /// The URL of the repository.
1038
    pub url: String,
1039
}
1040

1041
//@<user-preferences
1042
/// The user-specific configuration
1043
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
7✔
1044
#[ts(export)]
1045
pub struct UserPreferences {
1046
    /// The preferences for generating transcription courses.
1047
    #[serde(default)]
1048
    #[serde(skip_serializing_if = "Option::is_none")]
1049
    pub transcription: Option<TranscriptionPreferences>,
1050

1051
    /// The preferences for customizing the behavior of the scheduler.
1052
    #[serde(default)]
1053
    #[serde(skip_serializing_if = "Option::is_none")]
1054
    pub scheduler: Option<SchedulerPreferences>,
1055

1056
    /// The paths to ignore when opening the course library. The paths are relative to the
1057
    /// repository root. All child paths are also ignored. For example, adding the directory
1058
    /// "foo/bar" will ignore any courses in "foo/bar" or any of its subdirectories.
1059
    #[serde(default)]
1060
    #[serde(skip_serializing_if = "Vec::is_empty")]
1061
    pub ignored_paths: Vec<String>,
1062
}
1063
//>@user-preferences
1064

1065
#[cfg(test)]
1066
#[cfg_attr(coverage, coverage(off))]
1067
mod test {
1068
    use crate::data::*;
1069

1070
    // Verifies the conversion of mastery scores to float values.
1071
    #[test]
1072
    fn score_to_float() {
1073
        assert_eq!(1.0, MasteryScore::One.float_score());
1074
        assert_eq!(2.0, MasteryScore::Two.float_score());
1075
        assert_eq!(3.0, MasteryScore::Three.float_score());
1076
        assert_eq!(4.0, MasteryScore::Four.float_score());
1077
        assert_eq!(5.0, MasteryScore::Five.float_score());
1078

1079
        assert_eq!(1.0, f32::try_from(MasteryScore::One).unwrap());
1080
        assert_eq!(2.0, f32::try_from(MasteryScore::Two).unwrap());
1081
        assert_eq!(3.0, f32::try_from(MasteryScore::Three).unwrap());
1082
        assert_eq!(4.0, f32::try_from(MasteryScore::Four).unwrap());
1083
        assert_eq!(5.0, f32::try_from(MasteryScore::Five).unwrap());
1084
    }
1085

1086
    /// Verifies the conversion of floats to mastery scores.
1087
    #[test]
1088
    fn float_to_score() {
1089
        assert_eq!(MasteryScore::One, MasteryScore::try_from(1.0).unwrap());
1090
        assert_eq!(MasteryScore::Two, MasteryScore::try_from(2.0).unwrap());
1091
        assert_eq!(MasteryScore::Three, MasteryScore::try_from(3.0).unwrap());
1092
        assert_eq!(MasteryScore::Four, MasteryScore::try_from(4.0).unwrap());
1093
        assert_eq!(MasteryScore::Five, MasteryScore::try_from(5.0).unwrap());
1094
        assert!(MasteryScore::try_from(-1.0).is_err());
1095
        assert!(MasteryScore::try_from(0.0).is_err());
1096
        assert!(MasteryScore::try_from(3.5).is_err());
1097
        assert!(MasteryScore::try_from(5.1).is_err());
1098
    }
1099

1100
    /// Verifies that each type of manifest returns the correct unit type.
1101
    #[test]
1102
    fn get_unit_type() {
1103
        assert_eq!(
1104
            UnitType::Course,
1105
            CourseManifestBuilder::default()
1106
                .id("test")
1107
                .name("Test".to_string())
1108
                .dependencies(vec![])
1109
                .build()
1110
                .unwrap()
1111
                .get_unit_type()
1112
        );
1113
        assert_eq!(
1114
            UnitType::Lesson,
1115
            LessonManifestBuilder::default()
1116
                .id("test")
1117
                .course_id("test")
1118
                .name("Test".to_string())
1119
                .dependencies(vec![])
1120
                .build()
1121
                .unwrap()
1122
                .get_unit_type()
1123
        );
1124
        assert_eq!(
1125
            UnitType::Exercise,
1126
            ExerciseManifestBuilder::default()
1127
                .id("test")
1128
                .course_id("test")
1129
                .lesson_id("test")
1130
                .name("Test".to_string())
1131
                .exercise_type(ExerciseType::Procedural)
1132
                .exercise_asset(ExerciseAsset::FlashcardAsset {
1133
                    front_path: "front.png".to_string(),
1134
                    back_path: Some("back.png".to_string()),
1135
                })
1136
                .build()
1137
                .unwrap()
1138
                .get_unit_type()
1139
        );
1140
    }
1141

1142
    /// Verifies the `NormalizePaths` trait works for a `SoundSlice` asset.
1143
    #[test]
1144
    fn soundslice_normalize_paths() -> Result<()> {
1145
        let soundslice = ExerciseAsset::SoundSliceAsset {
1146
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1147
            description: Some("Test".to_string()),
1148
            backup: None,
1149
        };
1150
        soundslice.normalize_paths(Path::new("./"))?;
1151

1152
        let temp_dir = tempfile::tempdir()?;
1153
        let temp_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1154
        let soundslice = ExerciseAsset::SoundSliceAsset {
1155
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1156
            description: Some("Test".to_string()),
1157
            backup: Some(temp_file.path().as_os_str().to_str().unwrap().to_string()),
1158
        };
1159
        soundslice.normalize_paths(temp_dir.path())?;
1160
        Ok(())
1161
    }
1162

1163
    /// Tests that the `VerifyPaths` trait works for a `SoundSlice` asset.
1164
    #[test]
1165
    fn soundslice_verify_paths() -> Result<()> {
1166
        let soundslice = ExerciseAsset::SoundSliceAsset {
1167
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1168
            description: Some("Test".to_string()),
1169
            backup: None,
1170
        };
1171
        assert!(soundslice.verify_paths(Path::new("./"))?);
1172

1173
        let soundslice = ExerciseAsset::SoundSliceAsset {
1174
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1175
            description: Some("Test".to_string()),
1176
            backup: Some("./bad_file".to_string()),
1177
        };
1178
        assert!(!soundslice.verify_paths(Path::new("./"))?);
1179
        Ok(())
1180
    }
1181

1182
    /// Tests that the `VerifyPaths` trait works for a flashcard asset.
1183
    #[test]
1184
    fn flashcard_verify_paths() -> Result<()> {
1185
        // Verify a flashcard with no back.
1186
        let temp_dir = tempfile::tempdir()?;
1187
        let front_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1188
        let flashcard_asset = ExerciseAsset::FlashcardAsset {
1189
            front_path: front_file.path().as_os_str().to_str().unwrap().to_string(),
1190
            back_path: None,
1191
        };
1192
        assert!(flashcard_asset.verify_paths(temp_dir.path())?);
1193

1194
        // Verify a flashcard with front and back.
1195
        let back_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1196
        let flashcard_asset = ExerciseAsset::FlashcardAsset {
1197
            front_path: front_file.path().as_os_str().to_str().unwrap().to_string(),
1198
            back_path: Some(back_file.path().as_os_str().to_str().unwrap().to_string()),
1199
        };
1200
        assert!(flashcard_asset.verify_paths(temp_dir.path())?);
1201
        Ok(())
1202
    }
1203

1204
    /// Tests that the `VerifyPaths` trait works for a literacy asset.
1205
    #[test]
1206
    fn literacy_verify_paths() -> Result<()> {
1207
        let temp_dir = tempfile::tempdir()?;
1208
        let literacy_asset = ExerciseAsset::LiteracyAsset {
1209
            lesson_type: LiteracyLessonType::Reading,
1210
            examples: vec!["C".to_string(), "D".to_string()],
1211
            exceptions: vec!["E".to_string()],
1212
        };
1213
        assert!(literacy_asset.verify_paths(temp_dir.path())?);
1214
        Ok(())
1215
    }
1216

1217
    /// Verifies the `Display` trait for each unit type.
1218
    #[test]
1219
    fn unit_type_display() {
1220
        assert_eq!("Course", UnitType::Course.to_string());
1221
        assert_eq!("Lesson", UnitType::Lesson.to_string());
1222
        assert_eq!("Exercise", UnitType::Exercise.to_string());
1223
    }
1224

1225
    /// Verifies that normalizing a path works with the path to a valid file.
1226
    #[test]
1227
    fn normalize_good_path() -> Result<()> {
1228
        let temp_dir = tempfile::tempdir()?;
1229
        let temp_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1230
        let temp_file_path = temp_file.path().to_str().unwrap();
1231
        let normalized_path = normalize_path(temp_dir.path(), temp_file_path)?;
1232
        assert_eq!(
1233
            temp_dir.path().join(temp_file_path).to_str().unwrap(),
1234
            normalized_path
1235
        );
1236
        Ok(())
1237
    }
1238

1239
    /// Verifies that normalizing an absolute path returns the original path.
1240
    #[test]
1241
    fn normalize_absolute_path() {
1242
        let normalized_path = normalize_path(Path::new("/working/dir"), "/absolute/path").unwrap();
1243
        assert_eq!("/absolute/path", normalized_path,);
1244
    }
1245

1246
    /// Verifies that normalizing a path fails with the path to a missing file.
1247
    #[test]
1248
    fn normalize_bad_path() -> Result<()> {
1249
        let temp_dir = tempfile::tempdir()?;
1250
        let temp_file_path = "missing_file";
1251
        assert!(normalize_path(temp_dir.path(), temp_file_path).is_err());
1252
        Ok(())
1253
    }
1254

1255
    /// Verifies the default scheduler options are valid.
1256
    #[test]
1257
    fn valid_default_scheduler_options() {
1258
        let options = SchedulerOptions::default();
1259
        assert!(options.verify().is_ok());
1260
    }
1261

1262
    /// Verifies scheduler options with a batch size of 0 are invalid.
1263
    #[test]
1264
    fn scheduler_options_invalid_batch_size() {
1265
        let options = SchedulerOptions {
1266
            batch_size: 0,
1267
            ..Default::default()
1268
        };
1269
        assert!(options.verify().is_err());
1270
    }
1271

1272
    /// Verifies scheduler options with an invalid mastered window range are invalid.
1273
    #[test]
1274
    fn scheduler_options_invalid_mastered_window() {
1275
        let mut options = SchedulerOptions::default();
1276
        options.mastered_window_opts.range.1 = 4.9;
1277
        assert!(options.verify().is_err());
1278
    }
1279

1280
    /// Verifies scheduler options with an invalid new window range are invalid.
1281
    #[test]
1282
    fn scheduler_options_invalid_new_window() {
1283
        let mut options = SchedulerOptions::default();
1284
        options.new_window_opts.range.0 = 0.1;
1285
        assert!(options.verify().is_err());
1286
    }
1287

1288
    /// Verifies that scheduler options with a gap in the windows are invalid.
1289
    #[test]
1290
    fn scheduler_options_gap_in_windows() {
1291
        let mut options = SchedulerOptions::default();
1292
        options.new_window_opts.range.1 -= 0.1;
1293
        assert!(options.verify().is_err());
1294

1295
        let mut options = SchedulerOptions::default();
1296
        options.target_window_opts.range.1 -= 0.1;
1297
        assert!(options.verify().is_err());
1298

1299
        let mut options = SchedulerOptions::default();
1300
        options.current_window_opts.range.1 -= 0.1;
1301
        assert!(options.verify().is_err());
1302

1303
        let mut options = SchedulerOptions::default();
1304
        options.easy_window_opts.range.1 -= 0.1;
1305
        assert!(options.verify().is_err());
1306
    }
1307

1308
    /// Verifies that scheduler options with a percentage sum other than 1 are invalid.
1309
    #[test]
1310
    fn scheduler_options_invalid_percentage_sum() {
1311
        let mut options = SchedulerOptions::default();
1312
        options.target_window_opts.percentage -= 0.1;
1313
        assert!(options.verify().is_err());
1314
    }
1315

1316
    /// Verifies that valid passing score options are recognized as such.
1317
    #[test]
1318
    fn verify_passing_score_options() {
1319
        let options = PassingScoreOptions::default();
1320
        assert!(options.verify().is_ok());
1321

1322
        let options = PassingScoreOptions::ConstantScore(3.50);
1323
        assert!(options.verify().is_ok());
1324
    }
1325

1326
    /// Verifies that invalid passing score options are recognized as such.
1327
    #[test]
1328
    fn verify_passing_score_options_invalid() {
1329
        let options = PassingScoreOptions::ConstantScore(-1.0);
1330
        assert!(options.verify().is_err());
1331

1332
        let options = PassingScoreOptions::ConstantScore(6.0);
1333
        assert!(options.verify().is_err());
1334

1335
        let options = PassingScoreOptions::IncreasingScore {
1336
            starting_score: -1.0,
1337
            step_size: 0.0,
1338
            max_steps: 0,
1339
        };
1340
        assert!(options.verify().is_err());
1341

1342
        let options = PassingScoreOptions::IncreasingScore {
1343
            starting_score: 6.0,
1344
            step_size: 0.0,
1345
            max_steps: 0,
1346
        };
1347
        assert!(options.verify().is_err());
1348

1349
        let options = PassingScoreOptions::IncreasingScore {
1350
            starting_score: 3.50,
1351
            step_size: -1.0,
1352
            max_steps: 0,
1353
        };
1354
        assert!(options.verify().is_err());
1355
    }
1356

1357
    /// Verifies that the passing score is computed correctly.
1358
    #[test]
1359
    fn compute_passing_score() {
1360
        let options = PassingScoreOptions::ConstantScore(3.50);
1361
        assert_eq!(options.compute_score(0), 3.50);
1362
        assert_eq!(options.compute_score(1), 3.50);
1363
        assert_eq!(options.compute_score(2), 3.50);
1364
        // Clone the score for code coverage.
1365
        assert_eq!(options, options.clone());
1366

1367
        let options = PassingScoreOptions::default();
1368
        assert_eq!(options.compute_score(0), 3.50);
1369
        assert_eq!(options.compute_score(1), 3.51);
1370
        assert_eq!(options.compute_score(2), 3.52);
1371
        assert_eq!(options.compute_score(5), 3.55);
1372
        assert_eq!(options.compute_score(25), 3.75);
1373
        assert_eq!(options.compute_score(50), 3.75);
1374
        // Clone the score for code coverage.
1375
        assert_eq!(options, options.clone());
1376
    }
1377

1378
    /// Verifies that the default exercise type is Procedural. Written to satisfy code coverage.
1379
    #[test]
1380
    fn default_exercise_type() {
1381
        let exercise_type = ExerciseType::default();
1382
        assert_eq!(exercise_type, ExerciseType::Procedural);
1383
    }
1384

1385
    /// Verifies the clone method for the `RepositoryMetadata` struct. Written to satisfy code
1386
    /// coverage.
1387
    #[test]
1388
    fn repository_metadata_clone() {
1389
        let metadata = RepositoryMetadata {
1390
            id: "id".to_string(),
1391
            url: "url".to_string(),
1392
        };
1393
        assert_eq!(metadata, metadata.clone());
1394
    }
1395

1396
    /// Verifies the clone method for the `UserPreferences` struct. Written to satisfy code
1397
    /// coverage.
1398
    #[test]
1399
    fn user_preferences_clone() {
1400
        let preferences = UserPreferences {
1401
            transcription: Some(TranscriptionPreferences {
1402
                instruments: vec![],
1403
                download_path: Some("/a/b/c".to_owned()),
1404
                download_path_alias: Some("alias".to_owned()),
1405
            }),
1406
            scheduler: Some(SchedulerPreferences {
1407
                batch_size: Some(10),
1408
            }),
1409
            ignored_paths: vec!["courses/".to_owned()],
1410
        };
1411
        assert_eq!(preferences, preferences.clone());
1412
    }
1413

1414
    /// Verifies the clone method for the `ExerciseTrial` struct. Written to satisfy code coverage.
1415
    #[test]
1416
    fn exercise_trial_clone() {
1417
        let trial = ExerciseTrial {
1418
            score: 5.0,
1419
            timestamp: 1,
1420
        };
1421
        assert_eq!(trial, trial.clone());
1422
    }
1423

1424
    /// Verifies the clone method for the `UnitReward` struct. Written to satisfy code coverage.
1425
    #[test]
1426
    fn unit_reward_clone() {
1427
        let reward = UnitReward {
1428
            timestamp: 1,
1429
            reward: 1.0,
1430
        };
1431
        assert_eq!(reward, reward.clone());
1432
    }
1433
}
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