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

trane-project / trane / 14528077513

18 Apr 2025 01:49AM UTC coverage: 99.186% (-0.02%) from 99.203%
14528077513

push

github

web-flow
Optimize opening course library (#342)

94 of 97 new or added lines in 1 file covered. (96.91%)

6 existing lines in 2 files now uncovered.

5481 of 5526 relevant lines covered (99.19%)

151574.58 hits per line

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

97.87
/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::{Result, bail};
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)]
8✔
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 {
95,770✔
66
        match *self {
95,770✔
67
            Self::One => 1.0,
11,480✔
68
            Self::Two => 2.0,
2✔
69
            Self::Three => 3.0,
10✔
70
            Self::Four => 4.0,
8✔
71
            Self::Five => 5.0,
84,270✔
72
        }
73
    }
95,770✔
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)]
8✔
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.
119
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
8✔
120
#[ts(export)]
121
pub struct UnitReward {
122
    /// The reward assigned to the exercise. The value can be negative, zero, or positive.
123
    pub reward: f32,
124

125
    /// The weight assigned to the reward. Rewards from closer units are given more weight than
126
    /// those from distant units.
127
    pub weight: f32,
128

129
    /// The timestamp at which the reward was assigned.
130
    pub timestamp: i64,
131
}
132

133
/// The type of the units stored in the dependency graph.
134
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
8✔
135
#[ts(export)]
136
pub enum UnitType {
137
    /// A single task, which the student is meant to perform and assess.
138
    Exercise,
139

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

146
    /// A set of related lessons around one or more similar topics. Courses can depend on other
147
    /// lessons or courses.
148
    Course,
149
}
150

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

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

172
/// Converts a relative to an absolute path given a path and a working directory.
173
fn normalize_path(working_dir: &Path, path_str: &str) -> Result<String> {
27,350✔
174
    let path = Path::new(path_str);
27,350✔
175
    if path.is_absolute() {
27,350✔
176
        return Ok(path_str.to_string());
3✔
177
    }
27,347✔
178

27,347✔
179
    Ok(working_dir
27,347✔
180
        .join(path)
27,347✔
181
        .canonicalize()?
27,347✔
182
        .to_str()
27,346✔
183
        .unwrap_or(path_str)
27,346✔
184
        .to_string())
27,346✔
185
}
27,350✔
186

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

196
/// Trait to get the metadata from a lesson or course manifest.
197
pub trait GetMetadata {
198
    /// Returns the manifest's metadata.
199
    fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>>;
200
}
201

202
/// Trait to get the unit type from a manifest.
203
pub trait GetUnitType {
204
    /// Returns the type of the unit associated with the manifest.
205
    fn get_unit_type(&self) -> UnitType;
206
}
207

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

219
    /// An asset containing its content as a string.
220
    InlinedAsset {
221
        /// The content of the asset.
222
        content: String,
223
    },
224

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

234
impl NormalizePaths for BasicAsset {
235
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
3,534✔
236
        match &self {
3,534✔
237
            BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => {
UNCOV
238
                Ok(self.clone())
×
239
            }
240
            BasicAsset::MarkdownAsset { path } => {
3,534✔
241
                let abs_path = normalize_path(working_dir, path)?;
3,534✔
242
                Ok(BasicAsset::MarkdownAsset { path: abs_path })
3,534✔
243
            }
244
        }
245
    }
3,534✔
246
}
247

248
impl VerifyPaths for BasicAsset {
249
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
3,547✔
250
        match &self {
3,547✔
251
            BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => Ok(true),
1✔
252
            BasicAsset::MarkdownAsset { path } => {
3,546✔
253
                let abs_path = working_dir.join(Path::new(path));
3,546✔
254
                Ok(abs_path.exists())
3,546✔
255
            }
256
        }
257
    }
3,547✔
258
}
259

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

270
    /// The configuration for generating a literacy course.
271
    Literacy(LiteracyConfig),
272

273
    /// The configuration for generating a music piece course.
274
    MusicPiece(MusicPieceConfig),
275

276
    /// The configuration for generating a transcription course.
277
    Transcription(TranscriptionConfig),
278
}
279
//>@course-generator
280

281
/// A struct holding the results from running a course generator.
282
#[derive(Debug, PartialEq)]
283
pub struct GeneratedCourse {
284
    /// The lessons and exercise manifests generated for the course.
285
    pub lessons: Vec<(LessonManifest, Vec<ExerciseManifest>)>,
286

287
    /// Updated course metadata. If None, the existing metadata is used.
288
    pub updated_metadata: Option<BTreeMap<String, Vec<String>>>,
289

290
    /// Updated course instructions. If None, the existing instructions are used.
291
    pub updated_instructions: Option<BasicAsset>,
292
}
293

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

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

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

340
    /// The name of the course to be presented to the user.
341
    ///
342
    /// For example, "Basic Jazz Chords on Guitar".
343
    #[builder(default)]
344
    #[serde(default)]
345
    pub name: String,
346

347
    /// The IDs of all dependencies of this course.
348
    #[builder(default)]
349
    #[serde(default)]
350
    #[ts(as = "Vec<String>")]
351
    pub dependencies: Vec<Ustr>,
352

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

361
    /// An optional description of the course.
362
    #[builder(default)]
363
    #[serde(default)]
364
    #[serde(skip_serializing_if = "Option::is_none")]
365
    pub description: Option<String>,
366

367
    /// An optional list of the course's authors.
368
    #[builder(default)]
369
    #[serde(default)]
370
    #[serde(skip_serializing_if = "Option::is_none")]
371
    pub authors: Option<Vec<String>>,
372

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

385
    //>@lp-example-5
386
    /// An optional asset, which presents the material covered in the course.
387
    #[builder(default)]
388
    #[serde(default)]
389
    #[serde(skip_serializing_if = "Option::is_none")]
390
    pub course_material: Option<BasicAsset>,
391

392
    /// An optional asset, which presents instructions common to all exercises in the course.
393
    #[builder(default)]
394
    #[serde(default)]
395
    #[serde(skip_serializing_if = "Option::is_none")]
396
    pub course_instructions: Option<BasicAsset>,
397

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

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

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

439
impl GetMetadata for CourseManifest {
440
    fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>> {
24,527✔
441
        self.metadata.as_ref()
24,527✔
442
    }
24,527✔
443
}
444

445
impl GetUnitType for CourseManifest {
446
    fn get_unit_type(&self) -> UnitType {
1✔
447
        UnitType::Course
1✔
448
    }
1✔
449
}
450

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

462
    /// The IDs of all dependencies of this lesson.
463
    #[builder(default)]
464
    #[serde(default)]
465
    #[ts(as = "Vec<String>")]
466
    pub dependencies: Vec<Ustr>,
467

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

476
    /// The ID of the course to which the lesson belongs.
477
    #[builder(setter(into))]
478
    #[ts(as = "String")]
479
    pub course_id: Ustr,
480

481
    /// The name of the lesson to be presented to the user.
482
    ///
483
    /// For example, "Basic Jazz Major Chords".
484
    #[builder(default)]
485
    #[serde(default)]
486
    pub name: String,
487

488
    /// An optional description of the lesson.
489
    #[builder(default)]
490
    #[serde(default)]
491
    #[serde(skip_serializing_if = "Option::is_none")]
492
    pub description: Option<String>,
493

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

502
    /// An optional asset, which presents the material covered in the lesson.
503
    #[builder(default)]
504
    #[serde(default)]
505
    #[serde(skip_serializing_if = "Option::is_none")]
506
    pub lesson_material: Option<BasicAsset>,
507

508
    /// An optional asset, which presents instructions common to all exercises in the lesson.
509
    #[builder(default)]
510
    #[serde(default)]
511
    #[serde(skip_serializing_if = "Option::is_none")]
512
    pub lesson_instructions: Option<BasicAsset>,
513
}
514

515
impl NormalizePaths for LessonManifest {
516
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
1,308✔
517
        let mut clone = self.clone();
1,308✔
518
        if let Some(asset) = &self.lesson_instructions {
1,308✔
519
            clone.lesson_instructions = Some(asset.normalize_paths(working_dir)?);
1,308✔
520
        }
×
521
        if let Some(asset) = &self.lesson_material {
1,308✔
522
            clone.lesson_material = Some(asset.normalize_paths(working_dir)?);
1,308✔
523
        }
×
524
        Ok(clone)
1,308✔
525
    }
1,308✔
526
}
527

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

543
impl GetMetadata for LessonManifest {
544
    fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>> {
17,331✔
545
        self.metadata.as_ref()
17,331✔
546
    }
17,331✔
547
}
548

549
impl GetUnitType for LessonManifest {
550
    fn get_unit_type(&self) -> UnitType {
1✔
551
        UnitType::Lesson
1✔
552
    }
1✔
553
}
554

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

563
    /// Represents an exercises that requires more complex actions to be performed. For example, an
564
    /// exercise asking students to play a D Major chords in a piano.
565
    #[default]
566
    Procedural,
567
}
568

569
/// The asset storing the material of a particular exercise.
570
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
21✔
571
#[ts(export)]
572
pub enum ExerciseAsset {
573
    /// A basic asset storing the material of the exercise.
574
    BasicAsset(BasicAsset),
575

576
    /// An asset representing a flashcard with a front and back each stored in a markdown file. The
577
    /// first file stores the front (question) of the flashcard while the second file stores the
578
    /// back (answer).
579
    FlashcardAsset {
580
        /// The path to the file containing the front of the flashcard.
581
        front_path: String,
582

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

592
    /// An asset representing a literacy exercise.
593
    LiteracyAsset {
594
        /// The type of the lesson.
595
        lesson_type: LiteracyLessonType,
596

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

601
        /// The exceptions to the examples to use in the lesson's exercise.
602
        #[serde(default)]
603
        exceptions: Vec<String>,
604
    },
605

606
    /// An asset which stores a link to a SoundSlice.
607
    SoundSliceAsset {
608
        /// The link to the SoundSlice asset.
609
        link: String,
610

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

618
        /// An optional path to a MusicXML file containing the sheet music for the exercise.
619
        #[serde(default)]
620
        #[serde(skip_serializing_if = "Option::is_none")]
621
        backup: Option<String>,
622
    },
623

624
    /// A transcription asset, containing an exercise's content and an optional external link to the
625
    /// audio for the exercise.
626
    TranscriptionAsset {
627
        /// The content of the exercise.
628
        #[serde(default)]
629
        content: String,
630

631
        /// An optional link to the audio for the exercise.
632
        #[serde(default)]
633
        #[serde(skip_serializing_if = "Option::is_none")]
634
        external_link: Option<TranscriptionLink>,
635
    },
636
}
637

638
impl NormalizePaths for ExerciseAsset {
639
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
11,908✔
640
        match &self {
11,908✔
UNCOV
641
            ExerciseAsset::BasicAsset(asset) => Ok(ExerciseAsset::BasicAsset(
×
UNCOV
642
                asset.normalize_paths(working_dir)?,
×
643
            )),
644
            ExerciseAsset::FlashcardAsset {
645
                front_path,
11,906✔
646
                back_path,
11,906✔
647
            } => {
648
                let abs_front_path = normalize_path(working_dir, front_path)?;
11,906✔
649
                let abs_back_path = if let Some(back_path) = back_path {
11,906✔
650
                    Some(normalize_path(working_dir, back_path)?)
11,906✔
651
                } else {
UNCOV
652
                    None
×
653
                };
654
                Ok(ExerciseAsset::FlashcardAsset {
11,906✔
655
                    front_path: abs_front_path,
11,906✔
656
                    back_path: abs_back_path,
11,906✔
657
                })
11,906✔
658
            }
659
            ExerciseAsset::LiteracyAsset { .. } | ExerciseAsset::TranscriptionAsset { .. } => {
UNCOV
660
                Ok(self.clone())
×
661
            }
662
            ExerciseAsset::SoundSliceAsset {
663
                link,
2✔
664
                description,
2✔
665
                backup,
2✔
666
            } => match backup {
2✔
667
                None => Ok(self.clone()),
1✔
668
                Some(path) => {
1✔
669
                    let abs_path = normalize_path(working_dir, path)?;
1✔
670
                    Ok(ExerciseAsset::SoundSliceAsset {
1✔
671
                        link: link.clone(),
1✔
672
                        description: description.clone(),
1✔
673
                        backup: Some(abs_path),
1✔
674
                    })
1✔
675
                }
676
            },
677
        }
678
    }
11,908✔
679
}
680

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

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

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

730
    /// The ID of the course to which this exercise belongs.
731
    #[builder(setter(into))]
732
    #[ts(as = "String")]
733
    pub course_id: Ustr,
734

735
    /// The name of the exercise to be presented to the user.
736
    ///
737
    /// For example, "Exercise 1".
738
    #[builder(default)]
739
    #[serde(default)]
740
    pub name: String,
741

742
    /// An optional description of the exercise.
743
    #[builder(default)]
744
    #[serde(default)]
745
    #[serde(skip_serializing_if = "Option::is_none")]
746
    pub description: Option<String>,
747

748
    /// The type of knowledge the exercise tests.
749
    #[builder(default)]
750
    #[serde(default)]
751
    pub exercise_type: ExerciseType,
752

753
    /// The asset containing the exercise itself.
754
    pub exercise_asset: ExerciseAsset,
755
}
756

757
impl NormalizePaths for ExerciseManifest {
758
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
11,906✔
759
        let mut clone = self.clone();
11,906✔
760
        clone.exercise_asset = clone.exercise_asset.normalize_paths(working_dir)?;
11,906✔
761
        Ok(clone)
11,906✔
762
    }
11,906✔
763
}
764

765
impl VerifyPaths for ExerciseManifest {
766
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
11,947✔
767
        self.exercise_asset.verify_paths(working_dir)
11,947✔
768
    }
11,947✔
769
}
770

771
impl GetUnitType for ExerciseManifest {
772
    fn get_unit_type(&self) -> UnitType {
1✔
773
        UnitType::Exercise
1✔
774
    }
1✔
775
}
776

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

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

794
        /// The amount by which the score will increase for each additional depth. For example, if
795
        /// the unit is at depth 2, then the passing score will increase by `step_size * 2`.
796
        step_size: f32,
797

798
        /// The maximum number of steps that increase the passing score. Units that are deeper than
799
        /// this will have a passing score of `starting_score + step_size * max_steps`.
800
        max_steps: usize,
801
    },
802
}
803

804
impl Default for PassingScoreOptions {
805
    fn default() -> Self {
94✔
806
        PassingScoreOptions::IncreasingScore {
94✔
807
            starting_score: 3.50,
94✔
808
            step_size: 0.01,
94✔
809
            max_steps: 25,
94✔
810
        }
94✔
811
    }
94✔
812
}
813

814
impl PassingScoreOptions {
815
    /// Computes the passing score for a unit at the given depth.
816
    #[must_use]
817
    pub fn compute_score(&self, depth: usize) -> f32 {
1,385,853✔
818
        match self {
1,385,853✔
819
            PassingScoreOptions::ConstantScore(score) => score.min(5.0),
3✔
820
            PassingScoreOptions::IncreasingScore {
821
                starting_score,
1,385,850✔
822
                step_size,
1,385,850✔
823
                max_steps,
1,385,850✔
824
            } => {
1,385,850✔
825
                let steps = depth.min(*max_steps);
1,385,850✔
826
                (starting_score + step_size * steps as f32).min(5.0)
1,385,850✔
827
            }
828
        }
829
    }
1,385,853✔
830

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

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

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

878
impl MasteryWindow {
879
    /// Returns whether the given score falls within this window.
880
    #[must_use]
881
    pub fn in_window(&self, score: f32) -> bool {
9,753,510✔
882
        // Handle the special case of the window containing the maximum score. Scores greater than
9,753,510✔
883
        // 5.0 are allowed because float comparison is not exact.
9,753,510✔
884
        if self.range.1 >= 5.0 && score >= 5.0 {
9,753,510✔
885
            return true;
1,912,406✔
886
        }
7,841,104✔
887

7,841,104✔
888
        // Return true if the score falls within the range `[range.0, range.1)`.
7,841,104✔
889
        self.range.0 <= score && score < self.range.1
7,841,104✔
890
    }
9,753,510✔
891
}
892

893
/// Options to control how the scheduler selects exercises.
894
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
8✔
895
#[ts(export)]
896
pub struct SchedulerOptions {
897
    /// The maximum number of candidates to return each time the scheduler is called.
898
    pub batch_size: usize,
899

900
    /// The options of the new mastery window. That is, the window of exercises that have not
901
    /// received a score so far.
902
    pub new_window_opts: MasteryWindow,
903

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

908
    /// The options of the current mastery window. That is, the window of exercises that lie
909
    /// slightly outside the user's current abilities.
910
    pub current_window_opts: MasteryWindow,
911

912
    /// The options of the easy mastery window. That is, the window of exercises that lie well
913
    /// within the user's current abilities.
914
    pub easy_window_opts: MasteryWindow,
915

916
    /// The options for the mastered mastery window. That is, the window of exercises that the user
917
    /// has properly mastered.
918
    pub mastered_window_opts: MasteryWindow,
919

920
    /// The minimum average score of a unit required to move on to its dependents.
921
    pub passing_score: PassingScoreOptions,
922

923
    /// The minimum score required to supersede a unit. If unit A is superseded by B, then the
924
    /// exercises from unit A will not be shown once the score of unit B is greater than or equal to
925
    /// this value.
926
    pub superseding_score: f32,
927

928
    /// The number of trials to retrieve from the practice stats to compute an exercise's score.
929
    pub num_trials: usize,
930

931
    /// The number of rewards to retrieve from the practice rewards to compute a unit's reward.
932
    pub num_rewards: usize,
933
}
934

935
impl SchedulerOptions {
936
    #[must_use]
937
    fn float_equals(f1: f32, f2: f32) -> bool {
532✔
938
        (f1 - f2).abs() < f32::EPSILON
532✔
939
    }
532✔
940

941
    /// Verifies that the scheduler options are valid.
942
    pub fn verify(&self) -> Result<()> {
80✔
943
        // The batch size must be greater than 0.
80✔
944
        if self.batch_size == 0 {
80✔
945
            bail!("invalid scheduler options: batch_size must be greater than 0");
1✔
946
        }
79✔
947

79✔
948
        // The sum of the percentages of the mastery windows must be 1.0.
79✔
949
        if !Self::float_equals(
79✔
950
            self.mastered_window_opts.percentage
79✔
951
                + self.easy_window_opts.percentage
79✔
952
                + self.current_window_opts.percentage
79✔
953
                + self.target_window_opts.percentage
79✔
954
                + self.new_window_opts.percentage,
79✔
955
            1.0,
79✔
956
        ) {
79✔
957
            bail!(
1✔
958
                "invalid scheduler options: the sum of the percentages of the mastery windows \
1✔
959
                must be 1.0"
1✔
960
            );
961
        }
78✔
962

78✔
963
        // The new window's range must start at 0.0.
78✔
964
        if !Self::float_equals(self.new_window_opts.range.0, 0.0) {
78✔
965
            bail!("invalid scheduler options: the new window's range must start at 0.0");
1✔
966
        }
77✔
967

77✔
968
        // The mastered window's range must end at 5.0.
77✔
969
        if !Self::float_equals(self.mastered_window_opts.range.1, 5.0) {
77✔
970
            bail!("invalid scheduler options: the mastered window's range must end at 5.0");
1✔
971
        }
76✔
972

76✔
973
        // There must be no gaps in the mastery windows.
76✔
974
        if !Self::float_equals(
76✔
975
            self.new_window_opts.range.1,
76✔
976
            self.target_window_opts.range.0,
76✔
977
        ) || !Self::float_equals(
76✔
978
            self.target_window_opts.range.1,
75✔
979
            self.current_window_opts.range.0,
75✔
980
        ) || !Self::float_equals(
75✔
981
            self.current_window_opts.range.1,
74✔
982
            self.easy_window_opts.range.0,
74✔
983
        ) || !Self::float_equals(
74✔
984
            self.easy_window_opts.range.1,
73✔
985
            self.mastered_window_opts.range.0,
73✔
986
        ) {
73✔
987
            bail!("invalid scheduler options: there must be no gaps in the mastery windows");
4✔
988
        }
72✔
989

72✔
990
        Ok(())
72✔
991
    }
80✔
992
}
993

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

1030
/// Represents the scheduler's options that can be customized by the user.
1031
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
21✔
1032
#[ts(export)]
1033
pub struct SchedulerPreferences {
1034
    /// The maximum number of candidates to return each time the scheduler is called.
1035
    #[serde(default)]
1036
    pub batch_size: Option<usize>,
1037
}
1038

1039
/// Represents a repository containing Trane courses.
1040
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
8✔
1041
#[ts(export)]
1042
pub struct RepositoryMetadata {
1043
    /// The ID of the repository, which is also used to name the directory.
1044
    pub id: String,
1045

1046
    /// The URL of the repository.
1047
    pub url: String,
1048
}
1049

1050
//@<user-preferences
1051
/// The user-specific configuration
1052
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
8✔
1053
#[ts(export)]
1054
pub struct UserPreferences {
1055
    /// The preferences for generating transcription courses.
1056
    #[serde(default)]
1057
    #[serde(skip_serializing_if = "Option::is_none")]
1058
    pub transcription: Option<TranscriptionPreferences>,
1059

1060
    /// The preferences for customizing the behavior of the scheduler.
1061
    #[serde(default)]
1062
    #[serde(skip_serializing_if = "Option::is_none")]
1063
    pub scheduler: Option<SchedulerPreferences>,
1064

1065
    /// The paths to ignore when opening the course library. The paths are relative to the
1066
    /// repository root. All child paths are also ignored. For example, adding the directory
1067
    /// "foo/bar" will ignore any courses in "foo/bar" or any of its subdirectories.
1068
    #[serde(default)]
1069
    #[serde(skip_serializing_if = "Vec::is_empty")]
1070
    pub ignored_paths: Vec<String>,
1071
}
1072
//>@user-preferences
1073

1074
#[cfg(test)]
1075
#[cfg_attr(coverage, coverage(off))]
1076
mod test {
1077
    use crate::data::*;
1078

1079
    // Verifies the conversion of mastery scores to float values.
1080
    #[test]
1081
    fn score_to_float() {
1082
        assert_eq!(1.0, MasteryScore::One.float_score());
1083
        assert_eq!(2.0, MasteryScore::Two.float_score());
1084
        assert_eq!(3.0, MasteryScore::Three.float_score());
1085
        assert_eq!(4.0, MasteryScore::Four.float_score());
1086
        assert_eq!(5.0, MasteryScore::Five.float_score());
1087

1088
        assert_eq!(1.0, f32::try_from(MasteryScore::One).unwrap());
1089
        assert_eq!(2.0, f32::try_from(MasteryScore::Two).unwrap());
1090
        assert_eq!(3.0, f32::try_from(MasteryScore::Three).unwrap());
1091
        assert_eq!(4.0, f32::try_from(MasteryScore::Four).unwrap());
1092
        assert_eq!(5.0, f32::try_from(MasteryScore::Five).unwrap());
1093
    }
1094

1095
    /// Verifies the conversion of floats to mastery scores.
1096
    #[test]
1097
    fn float_to_score() {
1098
        assert_eq!(MasteryScore::One, MasteryScore::try_from(1.0).unwrap());
1099
        assert_eq!(MasteryScore::Two, MasteryScore::try_from(2.0).unwrap());
1100
        assert_eq!(MasteryScore::Three, MasteryScore::try_from(3.0).unwrap());
1101
        assert_eq!(MasteryScore::Four, MasteryScore::try_from(4.0).unwrap());
1102
        assert_eq!(MasteryScore::Five, MasteryScore::try_from(5.0).unwrap());
1103
        assert!(MasteryScore::try_from(-1.0).is_err());
1104
        assert!(MasteryScore::try_from(0.0).is_err());
1105
        assert!(MasteryScore::try_from(3.5).is_err());
1106
        assert!(MasteryScore::try_from(5.1).is_err());
1107
    }
1108

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

1151
    /// Verifies the `NormalizePaths` trait works for a `SoundSlice` asset.
1152
    #[test]
1153
    fn soundslice_normalize_paths() -> Result<()> {
1154
        let soundslice = ExerciseAsset::SoundSliceAsset {
1155
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1156
            description: Some("Test".to_string()),
1157
            backup: None,
1158
        };
1159
        soundslice.normalize_paths(Path::new("./"))?;
1160

1161
        let temp_dir = tempfile::tempdir()?;
1162
        let temp_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1163
        let soundslice = ExerciseAsset::SoundSliceAsset {
1164
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1165
            description: Some("Test".to_string()),
1166
            backup: Some(temp_file.path().as_os_str().to_str().unwrap().to_string()),
1167
        };
1168
        soundslice.normalize_paths(temp_dir.path())?;
1169
        Ok(())
1170
    }
1171

1172
    /// Tests that the `VerifyPaths` trait works for a `SoundSlice` asset.
1173
    #[test]
1174
    fn soundslice_verify_paths() -> Result<()> {
1175
        let soundslice = ExerciseAsset::SoundSliceAsset {
1176
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1177
            description: Some("Test".to_string()),
1178
            backup: None,
1179
        };
1180
        assert!(soundslice.verify_paths(Path::new("./"))?);
1181

1182
        let soundslice = ExerciseAsset::SoundSliceAsset {
1183
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1184
            description: Some("Test".to_string()),
1185
            backup: Some("./bad_file".to_string()),
1186
        };
1187
        assert!(!soundslice.verify_paths(Path::new("./"))?);
1188
        Ok(())
1189
    }
1190

1191
    /// Tests that the `VerifyPaths` trait works for a flashcard asset.
1192
    #[test]
1193
    fn flashcard_verify_paths() -> Result<()> {
1194
        // Verify a flashcard with no back.
1195
        let temp_dir = tempfile::tempdir()?;
1196
        let front_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1197
        let flashcard_asset = ExerciseAsset::FlashcardAsset {
1198
            front_path: front_file.path().as_os_str().to_str().unwrap().to_string(),
1199
            back_path: None,
1200
        };
1201
        assert!(flashcard_asset.verify_paths(temp_dir.path())?);
1202

1203
        // Verify a flashcard with front and back.
1204
        let back_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1205
        let flashcard_asset = ExerciseAsset::FlashcardAsset {
1206
            front_path: front_file.path().as_os_str().to_str().unwrap().to_string(),
1207
            back_path: Some(back_file.path().as_os_str().to_str().unwrap().to_string()),
1208
        };
1209
        assert!(flashcard_asset.verify_paths(temp_dir.path())?);
1210
        Ok(())
1211
    }
1212

1213
    /// Tests that the `VerifyPaths` trait works for a literacy asset.
1214
    #[test]
1215
    fn literacy_verify_paths() -> Result<()> {
1216
        let temp_dir = tempfile::tempdir()?;
1217
        let literacy_asset = ExerciseAsset::LiteracyAsset {
1218
            lesson_type: LiteracyLessonType::Reading,
1219
            examples: vec!["C".to_string(), "D".to_string()],
1220
            exceptions: vec!["E".to_string()],
1221
        };
1222
        assert!(literacy_asset.verify_paths(temp_dir.path())?);
1223
        Ok(())
1224
    }
1225

1226
    /// Verifies the `Display` trait for each unit type.
1227
    #[test]
1228
    fn unit_type_display() {
1229
        assert_eq!("Course", UnitType::Course.to_string());
1230
        assert_eq!("Lesson", UnitType::Lesson.to_string());
1231
        assert_eq!("Exercise", UnitType::Exercise.to_string());
1232
    }
1233

1234
    /// Verifies that normalizing a path works with the path to a valid file.
1235
    #[test]
1236
    fn normalize_good_path() -> Result<()> {
1237
        let temp_dir = tempfile::tempdir()?;
1238
        let temp_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1239
        let temp_file_path = temp_file.path().to_str().unwrap();
1240
        let normalized_path = normalize_path(temp_dir.path(), temp_file_path)?;
1241
        assert_eq!(
1242
            temp_dir.path().join(temp_file_path).to_str().unwrap(),
1243
            normalized_path
1244
        );
1245
        Ok(())
1246
    }
1247

1248
    /// Verifies that normalizing an absolute path returns the original path.
1249
    #[test]
1250
    fn normalize_absolute_path() {
1251
        let normalized_path = normalize_path(Path::new("/working/dir"), "/absolute/path").unwrap();
1252
        assert_eq!("/absolute/path", normalized_path,);
1253
    }
1254

1255
    /// Verifies that normalizing a path fails with the path to a missing file.
1256
    #[test]
1257
    fn normalize_bad_path() -> Result<()> {
1258
        let temp_dir = tempfile::tempdir()?;
1259
        let temp_file_path = "missing_file";
1260
        assert!(normalize_path(temp_dir.path(), temp_file_path).is_err());
1261
        Ok(())
1262
    }
1263

1264
    /// Verifies the default scheduler options are valid.
1265
    #[test]
1266
    fn valid_default_scheduler_options() {
1267
        let options = SchedulerOptions::default();
1268
        assert!(options.verify().is_ok());
1269
    }
1270

1271
    /// Verifies scheduler options with a batch size of 0 are invalid.
1272
    #[test]
1273
    fn scheduler_options_invalid_batch_size() {
1274
        let options = SchedulerOptions {
1275
            batch_size: 0,
1276
            ..Default::default()
1277
        };
1278
        assert!(options.verify().is_err());
1279
    }
1280

1281
    /// Verifies scheduler options with an invalid mastered window range are invalid.
1282
    #[test]
1283
    fn scheduler_options_invalid_mastered_window() {
1284
        let mut options = SchedulerOptions::default();
1285
        options.mastered_window_opts.range.1 = 4.9;
1286
        assert!(options.verify().is_err());
1287
    }
1288

1289
    /// Verifies scheduler options with an invalid new window range are invalid.
1290
    #[test]
1291
    fn scheduler_options_invalid_new_window() {
1292
        let mut options = SchedulerOptions::default();
1293
        options.new_window_opts.range.0 = 0.1;
1294
        assert!(options.verify().is_err());
1295
    }
1296

1297
    /// Verifies that scheduler options with a gap in the windows are invalid.
1298
    #[test]
1299
    fn scheduler_options_gap_in_windows() {
1300
        let mut options = SchedulerOptions::default();
1301
        options.new_window_opts.range.1 -= 0.1;
1302
        assert!(options.verify().is_err());
1303

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

1308
        let mut options = SchedulerOptions::default();
1309
        options.current_window_opts.range.1 -= 0.1;
1310
        assert!(options.verify().is_err());
1311

1312
        let mut options = SchedulerOptions::default();
1313
        options.easy_window_opts.range.1 -= 0.1;
1314
        assert!(options.verify().is_err());
1315
    }
1316

1317
    /// Verifies that scheduler options with a percentage sum other than 1 are invalid.
1318
    #[test]
1319
    fn scheduler_options_invalid_percentage_sum() {
1320
        let mut options = SchedulerOptions::default();
1321
        options.target_window_opts.percentage -= 0.1;
1322
        assert!(options.verify().is_err());
1323
    }
1324

1325
    /// Verifies that valid passing score options are recognized as such.
1326
    #[test]
1327
    fn verify_passing_score_options() {
1328
        let options = PassingScoreOptions::default();
1329
        assert!(options.verify().is_ok());
1330

1331
        let options = PassingScoreOptions::ConstantScore(3.50);
1332
        assert!(options.verify().is_ok());
1333
    }
1334

1335
    /// Verifies that invalid passing score options are recognized as such.
1336
    #[test]
1337
    fn verify_passing_score_options_invalid() {
1338
        let options = PassingScoreOptions::ConstantScore(-1.0);
1339
        assert!(options.verify().is_err());
1340

1341
        let options = PassingScoreOptions::ConstantScore(6.0);
1342
        assert!(options.verify().is_err());
1343

1344
        let options = PassingScoreOptions::IncreasingScore {
1345
            starting_score: -1.0,
1346
            step_size: 0.0,
1347
            max_steps: 0,
1348
        };
1349
        assert!(options.verify().is_err());
1350

1351
        let options = PassingScoreOptions::IncreasingScore {
1352
            starting_score: 6.0,
1353
            step_size: 0.0,
1354
            max_steps: 0,
1355
        };
1356
        assert!(options.verify().is_err());
1357

1358
        let options = PassingScoreOptions::IncreasingScore {
1359
            starting_score: 3.50,
1360
            step_size: -1.0,
1361
            max_steps: 0,
1362
        };
1363
        assert!(options.verify().is_err());
1364
    }
1365

1366
    /// Verifies that the passing score is computed correctly.
1367
    #[test]
1368
    fn compute_passing_score() {
1369
        let options = PassingScoreOptions::ConstantScore(3.50);
1370
        assert_eq!(options.compute_score(0), 3.50);
1371
        assert_eq!(options.compute_score(1), 3.50);
1372
        assert_eq!(options.compute_score(2), 3.50);
1373
        // Clone the score for code coverage.
1374
        assert_eq!(options, options.clone());
1375

1376
        let options = PassingScoreOptions::default();
1377
        assert_eq!(options.compute_score(0), 3.50);
1378
        assert_eq!(options.compute_score(1), 3.51);
1379
        assert_eq!(options.compute_score(2), 3.52);
1380
        assert_eq!(options.compute_score(5), 3.55);
1381
        assert_eq!(options.compute_score(25), 3.75);
1382
        assert_eq!(options.compute_score(50), 3.75);
1383
        // Clone the score for code coverage.
1384
        assert_eq!(options, options.clone());
1385
    }
1386

1387
    /// Verifies that the default exercise type is Procedural. Written to satisfy code coverage.
1388
    #[test]
1389
    fn default_exercise_type() {
1390
        let exercise_type = ExerciseType::default();
1391
        assert_eq!(exercise_type, ExerciseType::Procedural);
1392
    }
1393

1394
    /// Verifies the clone method for the `RepositoryMetadata` struct. Written to satisfy code
1395
    /// coverage.
1396
    #[test]
1397
    fn repository_metadata_clone() {
1398
        let metadata = RepositoryMetadata {
1399
            id: "id".to_string(),
1400
            url: "url".to_string(),
1401
        };
1402
        assert_eq!(metadata, metadata.clone());
1403
    }
1404

1405
    /// Verifies the clone method for the `UserPreferences` struct. Written to satisfy code
1406
    /// coverage.
1407
    #[test]
1408
    fn user_preferences_clone() {
1409
        let preferences = UserPreferences {
1410
            transcription: Some(TranscriptionPreferences {
1411
                instruments: vec![],
1412
                download_path: Some("/a/b/c".to_owned()),
1413
                download_path_alias: Some("alias".to_owned()),
1414
            }),
1415
            scheduler: Some(SchedulerPreferences {
1416
                batch_size: Some(10),
1417
            }),
1418
            ignored_paths: vec!["courses/".to_owned()],
1419
        };
1420
        assert_eq!(preferences, preferences.clone());
1421
    }
1422

1423
    /// Verifies the clone method for the `ExerciseTrial` struct. Written to satisfy code coverage.
1424
    #[test]
1425
    fn exercise_trial_clone() {
1426
        let trial = ExerciseTrial {
1427
            score: 5.0,
1428
            timestamp: 1,
1429
        };
1430
        assert_eq!(trial, trial.clone());
1431
    }
1432

1433
    /// Verifies the clone method for the `UnitReward` struct. Written to satisfy code coverage.
1434
    #[test]
1435
    fn unit_reward_clone() {
1436
        let reward = UnitReward {
1437
            timestamp: 1,
1438
            reward: 1.0,
1439
            weight: 1.0,
1440
        };
1441
        assert_eq!(reward, reward.clone());
1442
    }
1443
}
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