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

trane-project / trane / 12738122307

12 Jan 2025 11:42PM UTC coverage: 99.52% (+0.2%) from 99.355%
12738122307

Pull #321

github

web-flow
Merge 9912a9a58 into 056848884
Pull Request #321: Add more coverage exclusions

189 of 194 new or added lines in 5 files covered. (97.42%)

8 existing lines in 2 files now uncovered.

9528 of 9574 relevant lines covered (99.52%)

84758.25 hits per line

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

99.48
/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
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
6

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

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

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

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

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

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

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

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

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

78
impl TryFrom<MasteryScore> for f32 {
79
    type Error = ();
80

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

86
impl TryFrom<f32> for MasteryScore {
87
    type Error = ();
88

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

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

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

119
/// The type of the units stored in the dependency graph.
120
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize, TS)]
7✔
121
#[ts(export)]
122
pub enum UnitType {
123
    /// A single task, which the student is meant to perform and assess.
124
    Exercise,
125

126
    /// A set of related exercises. There are no dependencies between the exercises in a single
127
    /// lesson, so students could see them in any order. Lessons themselves can depend on other
128
    /// lessons or courses. There is also an implicit dependency between a lesson and the course to
129
    /// which it belongs.
130
    Lesson,
131

132
    /// A set of related lessons around one or more similar topics. Courses can depend on other
133
    /// lessons or courses.
134
    Course,
135
}
136

137
impl std::fmt::Display for UnitType {
138
    /// Implements the [Display](std::fmt::Display) trait for [`UnitType`].
139
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3✔
140
        match self {
3✔
141
            Self::Exercise => "Exercise".fmt(f),
1✔
142
            Self::Lesson => "Lesson".fmt(f),
1✔
143
            Self::Course => "Course".fmt(f),
1✔
144
        }
145
    }
3✔
146
}
147

148
/// Trait to convert relative paths to absolute paths so that objects stored in memory contain the
149
/// full path to all their assets.
150
pub trait NormalizePaths
151
where
152
    Self: Sized,
153
{
154
    /// Converts all relative paths in the object to absolute paths.
155
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self>;
156
}
157

158
/// Converts a relative to an absolute path given a path and a working directory.
159
fn normalize_path(working_dir: &Path, path_str: &str) -> Result<String> {
28,820✔
160
    let path = Path::new(path_str);
28,820✔
161
    if path.is_absolute() {
28,820✔
162
        return Ok(path_str.to_string());
163✔
163
    }
28,657✔
164

28,657✔
165
    Ok(working_dir
28,657✔
166
        .join(path)
28,657✔
167
        .canonicalize()?
28,657✔
168
        .to_str()
28,656✔
169
        .unwrap_or(path_str)
28,656✔
170
        .to_string())
28,656✔
171
}
28,820✔
172

173
/// Trait to verify that the paths in the object are valid.
174
pub trait VerifyPaths
175
where
176
    Self: Sized,
177
{
178
    /// Checks that all the paths mentioned in the object exist in disk.
179
    fn verify_paths(&self, working_dir: &Path) -> Result<bool>;
180
}
181

182
/// Trait to get the metadata from a lesson or course manifest.
183
pub trait GetMetadata {
184
    /// Returns the manifest's metadata.
185
    fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>>;
186
}
187

188
/// Trait to get the unit type from a manifest.
189
pub trait GetUnitType {
190
    /// Returns the type of the unit associated with the manifest.
191
    fn get_unit_type(&self) -> UnitType;
192
}
193

194
/// An asset attached to a unit, which could be used to store instructions, or present the material
195
/// introduced by a course or lesson.
196
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
57✔
197
#[ts(export)]
198
pub enum BasicAsset {
199
    /// An asset containing the path to a markdown file.
200
    MarkdownAsset {
201
        /// The path to the markdown file.
202
        path: String,
203
    },
204

205
    /// An asset containing its content as a string.
206
    InlinedAsset {
207
        /// The content of the asset.
208
        content: String,
209
    },
210

211
    /// An asset containing its content as a unique string. Useful for generating assets that are
212
    /// replicated across many units.
213
    InlinedUniqueAsset {
214
        /// The content of the asset.
215
        #[ts(as = "String")]
216
        content: Ustr,
217
    },
218
}
219

220
impl NormalizePaths for BasicAsset {
221
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
3,627✔
222
        match &self {
3,627✔
223
            BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => {
224
                Ok(self.clone())
1✔
225
            }
226
            BasicAsset::MarkdownAsset { path } => {
3,626✔
227
                let abs_path = normalize_path(working_dir, path)?;
3,626✔
228
                Ok(BasicAsset::MarkdownAsset { path: abs_path })
3,626✔
229
            }
230
        }
231
    }
3,627✔
232
}
233

234
impl VerifyPaths for BasicAsset {
235
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
3,639✔
236
        match &self {
3,639✔
237
            BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => Ok(true),
1✔
238
            BasicAsset::MarkdownAsset { path } => {
3,638✔
239
                let abs_path = working_dir.join(Path::new(path));
3,638✔
240
                Ok(abs_path.exists())
3,638✔
241
            }
242
        }
243
    }
3,639✔
244
}
245

246
//@<course-generator
247
/// A configuration used for generating special types of courses on the fly.
248
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
19✔
249
#[ts(export)]
250
pub enum CourseGenerator {
251
    /// The configuration for generating a knowledge base course. Currently, there are no
252
    /// configuration options, but the struct was added to implement the [GenerateManifests] trait
253
    /// and for future extensibility.
254
    KnowledgeBase(KnowledgeBaseConfig),
255

256
    /// The configuration for generating a literacy course.
257
    Literacy(LiteracyConfig),
258

259
    /// The configuration for generating a music piece course.
260
    MusicPiece(MusicPieceConfig),
261

262
    /// The configuration for generating a transcription course.
263
    Transcription(TranscriptionConfig),
264
}
265
//>@course-generator
266

267
/// A struct holding the results from running a course generator.
268
#[derive(Debug, PartialEq)]
269
pub struct GeneratedCourse {
270
    /// The lessons and exercise manifests generated for the course.
271
    pub lessons: Vec<(LessonManifest, Vec<ExerciseManifest>)>,
272

273
    /// Updated course metadata. If None, the existing metadata is used.
274
    pub updated_metadata: Option<BTreeMap<String, Vec<String>>>,
275

276
    /// Updated course instructions. If None, the existing instructions are used.
277
    pub updated_instructions: Option<BasicAsset>,
278
}
279

280
/// The trait to return all the generated lesson and exercise manifests for a course.
281
pub trait GenerateManifests {
282
    /// Returns all the generated lesson and exercise manifests for a course.
283
    fn generate_manifests(
284
        &self,
285
        course_root: &Path,
286
        course_manifest: &CourseManifest,
287
        preferences: &UserPreferences,
288
    ) -> Result<GeneratedCourse>;
289
}
290

291
impl GenerateManifests for CourseGenerator {
292
    fn generate_manifests(
23✔
293
        &self,
23✔
294
        course_root: &Path,
23✔
295
        course_manifest: &CourseManifest,
23✔
296
        preferences: &UserPreferences,
23✔
297
    ) -> Result<GeneratedCourse> {
23✔
298
        match self {
23✔
299
            CourseGenerator::KnowledgeBase(config) => {
2✔
300
                config.generate_manifests(course_root, course_manifest, preferences)
2✔
301
            }
302
            CourseGenerator::Literacy(config) => {
2✔
303
                config.generate_manifests(course_root, course_manifest, preferences)
2✔
304
            }
305
            CourseGenerator::MusicPiece(config) => {
5✔
306
                config.generate_manifests(course_root, course_manifest, preferences)
5✔
307
            }
308
            CourseGenerator::Transcription(config) => {
14✔
309
                config.generate_manifests(course_root, course_manifest, preferences)
14✔
310
            }
311
        }
312
    }
23✔
313
}
314

315
/// A manifest describing the contents of a course.
316
#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
7✔
317
#[ts(export)]
318
pub struct CourseManifest {
319
    /// The ID assigned to this course.
320
    ///
321
    /// For example, `music::instrument::guitar::basic_jazz_chords`.
322
    #[builder(setter(into))]
323
    #[ts(as = "String")]
324
    pub id: Ustr,
325

326
    /// The name of the course to be presented to the user.
327
    ///
328
    /// For example, "Basic Jazz Chords on Guitar".
329
    #[builder(default)]
330
    #[serde(default)]
331
    pub name: String,
332

333
    /// The IDs of all dependencies of this course.
334
    #[builder(default)]
335
    #[serde(default)]
336
    #[ts(as = "Vec<String>")]
337
    pub dependencies: Vec<Ustr>,
338

339
    /// The IDs of the courses or lessons that this course supersedes. If this course is mastered,
340
    /// then exercises from the superseded courses or lessons will no longer be shown to the
341
    /// student.
342
    #[builder(default)]
343
    #[serde(default)]
344
    #[ts(as = "Vec<String>")]
345
    pub superseded: Vec<Ustr>,
346

347
    /// An optional description of the course.
348
    #[builder(default)]
349
    #[serde(default)]
350
    #[serde(skip_serializing_if = "Option::is_none")]
351
    pub description: Option<String>,
352

353
    /// An optional list of the course's authors.
354
    #[builder(default)]
355
    #[serde(default)]
356
    #[serde(skip_serializing_if = "Option::is_none")]
357
    pub authors: Option<Vec<String>>,
358

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

371
    //>@lp-example-5
372
    /// An optional asset, which presents the material covered in the course.
373
    #[builder(default)]
374
    #[serde(default)]
375
    #[serde(skip_serializing_if = "Option::is_none")]
376
    pub course_material: Option<BasicAsset>,
377

378
    /// An optional asset, which presents instructions common to all exercises in the course.
379
    #[builder(default)]
380
    #[serde(default)]
381
    #[serde(skip_serializing_if = "Option::is_none")]
382
    pub course_instructions: Option<BasicAsset>,
383

384
    /// An optional configuration to generate material for this course. Generated courses allow
385
    /// easier creation of courses for specific purposes without requiring the manual creation of
386
    /// all the files a normal course would need.
387
    #[builder(default)]
388
    #[serde(default)]
389
    #[serde(skip_serializing_if = "Option::is_none")]
390
    pub generator_config: Option<CourseGenerator>,
391
}
392

393
impl NormalizePaths for CourseManifest {
394
    fn normalize_paths(&self, working_directory: &Path) -> Result<Self> {
478✔
395
        let mut clone = self.clone();
478✔
396
        match &self.course_instructions {
478✔
397
            None => (),
19✔
398
            Some(asset) => {
459✔
399
                clone.course_instructions = Some(asset.normalize_paths(working_directory)?);
459✔
400
            }
401
        }
402
        match &self.course_material {
478✔
403
            None => (),
19✔
404
            Some(asset) => clone.course_material = Some(asset.normalize_paths(working_directory)?),
459✔
405
        }
406
        Ok(clone)
478✔
407
    }
478✔
408
}
409

410
impl VerifyPaths for CourseManifest {
411
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
479✔
412
        // The paths mentioned in the instructions and material must both exist.
413
        let instructions_exist = match &self.course_instructions {
479✔
414
            None => true,
18✔
415
            Some(asset) => asset.verify_paths(working_dir)?,
461✔
416
        };
417
        let material_exists = match &self.course_material {
479✔
418
            None => true,
18✔
419
            Some(asset) => asset.verify_paths(working_dir)?,
461✔
420
        };
421
        Ok(instructions_exist && material_exists)
479✔
422
    }
479✔
423
}
424

425
impl GetMetadata for CourseManifest {
426
    fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>> {
24,493✔
427
        self.metadata.as_ref()
24,493✔
428
    }
24,493✔
429
}
430

431
impl GetUnitType for CourseManifest {
432
    fn get_unit_type(&self) -> UnitType {
1✔
433
        UnitType::Course
1✔
434
    }
1✔
435
}
436

437
/// A manifest describing the contents of a lesson.
438
#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
10,499✔
439
#[ts(export)]
440
pub struct LessonManifest {
441
    /// The ID assigned to this lesson.
442
    ///
443
    /// For example, `music::instrument::guitar::basic_jazz_chords::major_chords`.
444
    #[builder(setter(into))]
445
    #[ts(as = "String")]
446
    pub id: Ustr,
447

448
    /// The IDs of all dependencies of this lesson.
449
    #[builder(default)]
450
    #[serde(default)]
451
    #[ts(as = "Vec<String>")]
452
    pub dependencies: Vec<Ustr>,
453

454
    ///The IDs of the courses or lessons that this lesson supersedes. If this lesson is mastered,
455
    /// then exercises from the superseded courses or lessons will no longer be shown to the
456
    /// student.
457
    #[builder(default)]
458
    #[serde(default)]
459
    #[ts(as = "Vec<String>")]
460
    pub superseded: Vec<Ustr>,
461

462
    /// The ID of the course to which the lesson belongs.
463
    #[builder(setter(into))]
464
    #[ts(as = "String")]
465
    pub course_id: Ustr,
466

467
    /// The name of the lesson to be presented to the user.
468
    ///
469
    /// For example, "Basic Jazz Major Chords".
470
    #[builder(default)]
471
    #[serde(default)]
472
    pub name: String,
473

474
    /// An optional description of the lesson.
475
    #[builder(default)]
476
    #[serde(default)]
477
    #[serde(skip_serializing_if = "Option::is_none")]
478
    pub description: Option<String>,
479

480
    //// A mapping of String keys to a list of String values. For example, ("key", ["C"]) could
481
    /// be attached to a lesson named "C Major Scale". The purpose is the same as the metadata
482
    /// stored in the course manifest but allows finer control over which lessons are selected.
483
    #[builder(default)]
484
    #[serde(default)]
485
    #[serde(skip_serializing_if = "Option::is_none")]
486
    pub metadata: Option<BTreeMap<String, Vec<String>>>,
487

488
    /// An optional asset, which presents the material covered in the lesson.
489
    #[builder(default)]
490
    #[serde(default)]
491
    #[serde(skip_serializing_if = "Option::is_none")]
492
    pub lesson_material: Option<BasicAsset>,
493

494
    /// An optional asset, which presents instructions common to all exercises in the lesson.
495
    #[builder(default)]
496
    #[serde(default)]
497
    #[serde(skip_serializing_if = "Option::is_none")]
498
    pub lesson_instructions: Option<BasicAsset>,
499
}
500

501
impl NormalizePaths for LessonManifest {
502
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
1,354✔
503
        let mut clone = self.clone();
1,354✔
504
        if let Some(asset) = &self.lesson_instructions {
1,354✔
505
            clone.lesson_instructions = Some(asset.normalize_paths(working_dir)?);
1,354✔
UNCOV
506
        }
×
507
        if let Some(asset) = &self.lesson_material {
1,354✔
508
            clone.lesson_material = Some(asset.normalize_paths(working_dir)?);
1,354✔
UNCOV
509
        }
×
510
        Ok(clone)
1,354✔
511
    }
1,354✔
512
}
513

514
impl VerifyPaths for LessonManifest {
515
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
1,359✔
516
        // The paths mentioned in the instructions and material must both exist.
517
        let instruction_exists = match &self.lesson_instructions {
1,359✔
518
            None => true,
1✔
519
            Some(asset) => asset.verify_paths(working_dir)?,
1,358✔
520
        };
521
        let material_exists = match &self.lesson_material {
1,359✔
522
            None => true,
1✔
523
            Some(asset) => asset.verify_paths(working_dir)?,
1,358✔
524
        };
525
        Ok(instruction_exists && material_exists)
1,359✔
526
    }
1,359✔
527
}
528

529
impl GetMetadata for LessonManifest {
530
    fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>> {
17,306✔
531
        self.metadata.as_ref()
17,306✔
532
    }
17,306✔
533
}
534

535
impl GetUnitType for LessonManifest {
536
    fn get_unit_type(&self) -> UnitType {
1✔
537
        UnitType::Lesson
1✔
538
    }
1✔
539
}
540

541
/// The type of knowledge tested by an exercise.
542
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
19✔
543
#[ts(export)]
544
pub enum ExerciseType {
545
    /// Represents an exercise that tests mastery of factual knowledge. For example, an exercise
546
    /// asking students to name the notes in a D Major chord.
547
    Declarative,
548

549
    /// Represents an exercises that requires more complex actions to be performed. For example, an
550
    /// exercise asking students to play a D Major chords in a piano.
551
    #[default]
552
    Procedural,
553
}
554

555
/// The asset storing the material of a particular exercise.
556
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
19✔
557
#[ts(export)]
558
pub enum ExerciseAsset {
559
    /// A basic asset storing the material of the exercise.
560
    BasicAsset(BasicAsset),
561

562
    /// An asset representing a flashcard with a front and back each stored in a markdown file. The
563
    /// first file stores the front (question) of the flashcard while the second file stores the
564
    /// back (answer).
565
    FlashcardAsset {
566
        /// The path to the file containing the front of the flashcard.
567
        front_path: String,
568

569
        /// The path to the file containing the back of the flashcard. This path is optional,
570
        /// because a flashcard is not required to provide an answer. For example, the exercise is
571
        /// open-ended, or it is referring to an external resource which contains the exercise and
572
        /// possibly the answer.
573
        #[serde(default)]
574
        #[serde(skip_serializing_if = "Option::is_none")]
575
        back_path: Option<String>,
576
    },
577

578
    /// An asset representing a literacy exercise.
579
    LiteracyAsset {
580
        /// The type of the lesson.
581
        lesson_type: LiteracyLesson,
582

583
        /// The examples to use in the lesson's exercise.
584
        #[serde(default)]
585
        examples: Vec<String>,
586

587
        /// The exceptions to the examples to use in the lesson's exercise.
588
        #[serde(default)]
589
        exceptions: Vec<String>,
590
    },
591

592
    /// An asset which stores a link to a SoundSlice.
593
    SoundSliceAsset {
594
        /// The link to the SoundSlice asset.
595
        link: String,
596

597
        /// An optional description of the exercise tied to this asset. For example, "Play this
598
        /// slice in the key of D Major" or "Practice measures 1 through 4". A missing description
599
        /// implies the entire slice should be practiced as is.
600
        #[serde(default)]
601
        #[serde(skip_serializing_if = "Option::is_none")]
602
        description: Option<String>,
603

604
        /// An optional path to a MusicXML file containing the sheet music for the exercise.
605
        #[serde(default)]
606
        #[serde(skip_serializing_if = "Option::is_none")]
607
        backup: Option<String>,
608
    },
609

610
    /// A transcription asset, containing an exercise's content and an optional external link to the
611
    /// audio for the exercise.
612
    TranscriptionAsset {
613
        /// The content of the exercise.
614
        #[serde(default)]
615
        content: String,
616

617
        /// An optional link to the audio for the exercise.
618
        #[serde(default)]
619
        #[serde(skip_serializing_if = "Option::is_none")]
620
        external_link: Option<TranscriptionLink>,
621
    },
622
}
623

624
impl NormalizePaths for ExerciseAsset {
625
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
13,158✔
626
        match &self {
13,158✔
627
            ExerciseAsset::BasicAsset(asset) => Ok(ExerciseAsset::BasicAsset(
1✔
628
                asset.normalize_paths(working_dir)?,
1✔
629
            )),
630
            ExerciseAsset::FlashcardAsset {
631
                front_path,
12,615✔
632
                back_path,
12,615✔
633
            } => {
634
                let abs_front_path = normalize_path(working_dir, front_path)?;
12,615✔
635
                let abs_back_path = if let Some(back_path) = back_path {
12,615✔
636
                    Some(normalize_path(working_dir, back_path)?)
12,575✔
637
                } else {
638
                    None
40✔
639
                };
640
                Ok(ExerciseAsset::FlashcardAsset {
12,615✔
641
                    front_path: abs_front_path,
12,615✔
642
                    back_path: abs_back_path,
12,615✔
643
                })
12,615✔
644
            }
645
            ExerciseAsset::LiteracyAsset { .. } | ExerciseAsset::TranscriptionAsset { .. } => {
646
                Ok(self.clone())
496✔
647
            }
648
            ExerciseAsset::SoundSliceAsset {
649
                link,
46✔
650
                description,
46✔
651
                backup,
46✔
652
            } => match backup {
46✔
653
                None => Ok(self.clone()),
45✔
654
                Some(path) => {
1✔
655
                    let abs_path = normalize_path(working_dir, path)?;
1✔
656
                    Ok(ExerciseAsset::SoundSliceAsset {
1✔
657
                        link: link.clone(),
1✔
658
                        description: description.clone(),
1✔
659
                        backup: Some(abs_path),
1✔
660
                    })
1✔
661
                }
662
            },
663
        }
664
    }
13,158✔
665
}
666

667
impl VerifyPaths for ExerciseAsset {
668
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
12,560✔
669
        match &self {
12,560✔
670
            ExerciseAsset::BasicAsset(asset) => asset.verify_paths(working_dir),
1✔
671
            ExerciseAsset::FlashcardAsset {
672
                front_path,
12,557✔
673
                back_path,
12,557✔
674
            } => {
12,557✔
675
                let front_abs_path = working_dir.join(Path::new(front_path));
12,557✔
676
                if let Some(back_path) = back_path {
12,557✔
677
                    // The paths to the front and back of the flashcard must both exist.
678
                    let back_abs_path = working_dir.join(Path::new(back_path));
12,556✔
679
                    Ok(front_abs_path.exists() && back_abs_path.exists())
12,556✔
680
                } else {
681
                    // If the back of the flashcard is missing, then the front must exist.
682
                    Ok(front_abs_path.exists())
1✔
683
                }
684
            }
685
            ExerciseAsset::LiteracyAsset { .. } | ExerciseAsset::TranscriptionAsset { .. } => {
UNCOV
686
                Ok(true)
×
687
            }
688
            ExerciseAsset::SoundSliceAsset { backup, .. } => match backup {
2✔
689
                None => Ok(true),
1✔
690
                Some(path) => {
1✔
691
                    // The backup path must exist.
1✔
692
                    let abs_path = working_dir.join(Path::new(path));
1✔
693
                    Ok(abs_path.exists())
1✔
694
                }
695
            },
696
        }
697
    }
12,560✔
698
}
699

700
/// Manifest describing a single exercise.
701
#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
45,829✔
702
#[ts(export)]
703
pub struct ExerciseManifest {
704
    /// The ID assigned to this exercise.
705
    ///
706
    /// For example, `music::instrument::guitar::basic_jazz_chords::major_chords::exercise_1`.
707
    #[builder(setter(into))]
708
    #[ts(as = "String")]
709
    pub id: Ustr,
710

711
    /// The ID of the lesson to which this exercise belongs.
712
    #[builder(setter(into))]
713
    #[ts(as = "String")]
714
    pub lesson_id: Ustr,
715

716
    /// The ID of the course to which this exercise belongs.
717
    #[builder(setter(into))]
718
    #[ts(as = "String")]
719
    pub course_id: Ustr,
720

721
    /// The name of the exercise to be presented to the user.
722
    ///
723
    /// For example, "Exercise 1".
724
    #[builder(default)]
725
    #[serde(default)]
726
    pub name: String,
727

728
    /// An optional description of the exercise.
729
    #[builder(default)]
730
    #[serde(default)]
731
    #[serde(skip_serializing_if = "Option::is_none")]
732
    pub description: Option<String>,
733

734
    /// The type of knowledge the exercise tests.
735
    #[builder(default)]
736
    #[serde(default)]
737
    pub exercise_type: ExerciseType,
738

739
    /// The asset containing the exercise itself.
740
    pub exercise_asset: ExerciseAsset,
741
}
742

743
impl NormalizePaths for ExerciseManifest {
744
    fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
13,156✔
745
        let mut clone = self.clone();
13,156✔
746
        clone.exercise_asset = clone.exercise_asset.normalize_paths(working_dir)?;
13,156✔
747
        Ok(clone)
13,156✔
748
    }
13,156✔
749
}
750

751
impl VerifyPaths for ExerciseManifest {
752
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
12,556✔
753
        self.exercise_asset.verify_paths(working_dir)
12,556✔
754
    }
12,556✔
755
}
756

757
impl GetUnitType for ExerciseManifest {
758
    fn get_unit_type(&self) -> UnitType {
1✔
759
        UnitType::Exercise
1✔
760
    }
1✔
761
}
762

763
/// Options to compute the passing score for a unit.
764
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
19✔
765
#[ts(export)]
766
pub enum PassingScoreOptions {
767
    /// The passing score will be a fixed value. A unit will be considered mastered if the average
768
    /// score of all its exercises is greater than or equal to this value.
769
    ConstantScore(f32),
770

771
    /// The score will start at a fixed value and increase by a fixed amount based on the depth of
772
    /// the unit relative to the starting unit. This is useful for allowing users to make faster
773
    /// progress at the beginning, so to avoid boredom. Once enough of the graph has been mastered,
774
    /// the passing score will settle to a fixed value.
775
    IncreasingScore {
776
        /// The initial score. The units at the starting depth will use this value as their passing
777
        /// score.
778
        starting_score: f32,
779

780
        /// The amount by which the score will increase for each additional depth. For example, if
781
        /// the unit is at depth 2, then the passing score will increase by `step_size * 2`.
782
        step_size: f32,
783

784
        /// The maximum number of steps that increase the passing score. Units that are deeper than
785
        /// this will have a passing score of `starting_score + step_size * max_steps`.
786
        max_steps: usize,
787
    },
788
}
789

790
impl Default for PassingScoreOptions {
791
    fn default() -> Self {
94✔
792
        PassingScoreOptions::IncreasingScore {
94✔
793
            starting_score: 3.50,
94✔
794
            step_size: 0.01,
94✔
795
            max_steps: 25,
94✔
796
        }
94✔
797
    }
94✔
798
}
799

800
impl PassingScoreOptions {
801
    /// Computes the passing score for a unit at the given depth.
802
    #[must_use]
803
    pub fn compute_score(&self, depth: usize) -> f32 {
1,834,155✔
804
        match self {
1,834,155✔
805
            PassingScoreOptions::ConstantScore(score) => score.min(5.0),
3✔
806
            PassingScoreOptions::IncreasingScore {
807
                starting_score,
1,834,152✔
808
                step_size,
1,834,152✔
809
                max_steps,
1,834,152✔
810
            } => {
1,834,152✔
811
                let steps = depth.min(*max_steps);
1,834,152✔
812
                (starting_score + step_size * steps as f32).min(5.0)
1,834,152✔
813
            }
814
        }
815
    }
1,834,155✔
816

817
    /// Verifies that the options are valid.
818
    pub fn verify(&self) -> Result<()> {
7✔
819
        match self {
7✔
820
            PassingScoreOptions::ConstantScore(score) => {
3✔
821
                if *score < 0.0 || *score > 5.0 {
3✔
822
                    bail!("Invalid score: {}", score);
2✔
823
                }
1✔
824
                Ok(())
1✔
825
            }
826
            PassingScoreOptions::IncreasingScore {
827
                starting_score,
4✔
828
                step_size,
4✔
829
                ..
4✔
830
            } => {
4✔
831
                if *starting_score < 0.0 || *starting_score > 5.0 {
4✔
832
                    bail!("Invalid starting score: {}", starting_score);
2✔
833
                }
2✔
834
                if *step_size < 0.0 {
2✔
835
                    bail!("Invalid step size: {}", step_size);
1✔
836
                }
1✔
837
                Ok(())
1✔
838
            }
839
        }
840
    }
7✔
841
}
842

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

858
    /// The range of scores which fall on this window. Scores whose values are in the range
859
    /// `[range.0, range.1)` fall within this window. If `range.1` is equal to 5.0 (the float
860
    /// representation of the maximum possible score), then the range becomes inclusive.
861
    pub range: (f32, f32),
862
}
863

864
impl MasteryWindow {
865
    /// Returns whether the given score falls within this window.
866
    #[must_use]
867
    pub fn in_window(&self, score: f32) -> bool {
9,252,395✔
868
        // Handle the special case of the window containing the maximum score. Scores greater than
9,252,395✔
869
        // 5.0 are allowed because float comparison is not exact.
9,252,395✔
870
        if self.range.1 >= 5.0 && score >= 5.0 {
9,252,395✔
871
            return true;
1,775,356✔
872
        }
7,477,039✔
873

7,477,039✔
874
        // Return true if the score falls within the range `[range.0, range.1)`.
7,477,039✔
875
        self.range.0 <= score && score < self.range.1
7,477,039✔
876
    }
9,252,395✔
877
}
878

879
/// Options to control how the scheduler selects exercises.
880
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
7✔
881
#[ts(export)]
882
pub struct SchedulerOptions {
883
    /// The maximum number of candidates to return each time the scheduler is called.
884
    pub batch_size: usize,
885

886
    /// The options of the new mastery window. That is, the window of exercises that have not
887
    /// received a score so far.
888
    pub new_window_opts: MasteryWindow,
889

890
    /// The options of the target mastery window. That is, the window of exercises that lie outside
891
    /// the user's current abilities.
892
    pub target_window_opts: MasteryWindow,
893

894
    /// The options of the current mastery window. That is, the window of exercises that lie
895
    /// slightly outside the user's current abilities.
896
    pub current_window_opts: MasteryWindow,
897

898
    /// The options of the easy mastery window. That is, the window of exercises that lie well
899
    /// within the user's current abilities.
900
    pub easy_window_opts: MasteryWindow,
901

902
    /// The options for the mastered mastery window. That is, the window of exercises that the user
903
    /// has properly mastered.
904
    pub mastered_window_opts: MasteryWindow,
905

906
    /// The minimum average score of a unit required to move on to its dependents.
907
    pub passing_score: PassingScoreOptions,
908

909
    /// The minimum score required to supersede a unit. If unit A is superseded by B, then the
910
    /// exercises from unit A will not be shown once the score of unit B is greater than or equal to
911
    /// this value.
912
    pub superseding_score: f32,
913

914
    /// The number of trials to retrieve from the practice stats to compute an exercise's score.
915
    pub num_trials: usize,
916
}
917

918
impl SchedulerOptions {
919
    #[must_use]
920
    fn float_equals(f1: f32, f2: f32) -> bool {
532✔
921
        (f1 - f2).abs() < f32::EPSILON
532✔
922
    }
532✔
923

924
    /// Verifies that the scheduler options are valid.
925
    pub fn verify(&self) -> Result<()> {
80✔
926
        // The batch size must be greater than 0.
80✔
927
        if self.batch_size == 0 {
80✔
928
            bail!("invalid scheduler options: batch_size must be greater than 0");
1✔
929
        }
79✔
930

79✔
931
        // The sum of the percentages of the mastery windows must be 1.0.
79✔
932
        if !Self::float_equals(
79✔
933
            self.mastered_window_opts.percentage
79✔
934
                + self.easy_window_opts.percentage
79✔
935
                + self.current_window_opts.percentage
79✔
936
                + self.target_window_opts.percentage
79✔
937
                + self.new_window_opts.percentage,
79✔
938
            1.0,
79✔
939
        ) {
79✔
940
            bail!(
1✔
941
                "invalid scheduler options: the sum of the percentages of the mastery windows \
1✔
942
                must be 1.0"
1✔
943
            );
1✔
944
        }
78✔
945

78✔
946
        // The new window's range must start at 0.0.
78✔
947
        if !Self::float_equals(self.new_window_opts.range.0, 0.0) {
78✔
948
            bail!("invalid scheduler options: the new window's range must start at 0.0");
1✔
949
        }
77✔
950

77✔
951
        // The mastered window's range must end at 5.0.
77✔
952
        if !Self::float_equals(self.mastered_window_opts.range.1, 5.0) {
77✔
953
            bail!("invalid scheduler options: the mastered window's range must end at 5.0");
1✔
954
        }
76✔
955

76✔
956
        // There must be no gaps in the mastery windows.
76✔
957
        if !Self::float_equals(
76✔
958
            self.new_window_opts.range.1,
76✔
959
            self.target_window_opts.range.0,
76✔
960
        ) || !Self::float_equals(
76✔
961
            self.target_window_opts.range.1,
75✔
962
            self.current_window_opts.range.0,
75✔
963
        ) || !Self::float_equals(
75✔
964
            self.current_window_opts.range.1,
74✔
965
            self.easy_window_opts.range.0,
74✔
966
        ) || !Self::float_equals(
74✔
967
            self.easy_window_opts.range.1,
73✔
968
            self.mastered_window_opts.range.0,
73✔
969
        ) {
73✔
970
            bail!("invalid scheduler options: there must be no gaps in the mastery windows");
4✔
971
        }
72✔
972

72✔
973
        Ok(())
72✔
974
    }
80✔
975
}
976

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

1012
/// Represents the scheduler's options that can be customized by the user.
1013
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
19✔
1014
#[ts(export)]
1015
pub struct SchedulerPreferences {
1016
    /// The maximum number of candidates to return each time the scheduler is called.
1017
    #[serde(default)]
1018
    pub batch_size: Option<usize>,
1019
}
1020

1021
/// Represents a repository containing Trane courses.
1022
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize, TS)]
7✔
1023
#[ts(export)]
1024
pub struct RepositoryMetadata {
1025
    /// The ID of the repository, which is also used to name the directory.
1026
    pub id: String,
1027

1028
    /// The URL of the repository.
1029
    pub url: String,
1030
}
1031

1032
//@<user-preferences
1033
/// The user-specific configuration
1034
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize, TS)]
7✔
1035
#[ts(export)]
1036
pub struct UserPreferences {
1037
    /// The preferences for generating transcription courses.
1038
    #[serde(default)]
1039
    #[serde(skip_serializing_if = "Option::is_none")]
1040
    pub transcription: Option<TranscriptionPreferences>,
1041

1042
    /// The preferences for customizing the behavior of the scheduler.
1043
    #[serde(default)]
1044
    #[serde(skip_serializing_if = "Option::is_none")]
1045
    pub scheduler: Option<SchedulerPreferences>,
1046

1047
    /// The paths to ignore when opening the course library. The paths are relative to the
1048
    /// repository root. All child paths are also ignored. For example, adding the directory
1049
    /// "foo/bar" will ignore any courses in "foo/bar" or any of its subdirectories.
1050
    #[serde(default)]
1051
    #[serde(skip_serializing_if = "Vec::is_empty")]
1052
    pub ignored_paths: Vec<String>,
1053
}
1054
//>@user-preferences
1055

1056
#[cfg(test)]
1057
mod test {
1058
    use crate::data::*;
1059

1060
    // Verifies the conversion of mastery scores to float values.
1061
    #[test]
1062
    fn score_to_float() {
1✔
1063
        assert_eq!(1.0, MasteryScore::One.float_score());
1✔
1064
        assert_eq!(2.0, MasteryScore::Two.float_score());
1✔
1065
        assert_eq!(3.0, MasteryScore::Three.float_score());
1✔
1066
        assert_eq!(4.0, MasteryScore::Four.float_score());
1✔
1067
        assert_eq!(5.0, MasteryScore::Five.float_score());
1✔
1068

1069
        assert_eq!(1.0, f32::try_from(MasteryScore::One).unwrap());
1✔
1070
        assert_eq!(2.0, f32::try_from(MasteryScore::Two).unwrap());
1✔
1071
        assert_eq!(3.0, f32::try_from(MasteryScore::Three).unwrap());
1✔
1072
        assert_eq!(4.0, f32::try_from(MasteryScore::Four).unwrap());
1✔
1073
        assert_eq!(5.0, f32::try_from(MasteryScore::Five).unwrap());
1✔
1074
    }
1✔
1075

1076
    /// Verifies the conversion of floats to mastery scores.
1077
    #[test]
1078
    fn float_to_score() {
1✔
1079
        assert_eq!(MasteryScore::One, MasteryScore::try_from(1.0).unwrap());
1✔
1080
        assert_eq!(MasteryScore::Two, MasteryScore::try_from(2.0).unwrap());
1✔
1081
        assert_eq!(MasteryScore::Three, MasteryScore::try_from(3.0).unwrap());
1✔
1082
        assert_eq!(MasteryScore::Four, MasteryScore::try_from(4.0).unwrap());
1✔
1083
        assert_eq!(MasteryScore::Five, MasteryScore::try_from(5.0).unwrap());
1✔
1084
        assert!(MasteryScore::try_from(-1.0).is_err());
1✔
1085
        assert!(MasteryScore::try_from(0.0).is_err());
1✔
1086
        assert!(MasteryScore::try_from(3.5).is_err());
1✔
1087
        assert!(MasteryScore::try_from(5.1).is_err());
1✔
1088
    }
1✔
1089

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

1132
    /// Verifies the `NormalizePaths` trait works for a `SoundSlice` asset.
1133
    #[test]
1134
    fn soundslice_normalize_paths() -> Result<()> {
1✔
1135
        let soundslice = ExerciseAsset::SoundSliceAsset {
1✔
1136
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1✔
1137
            description: Some("Test".to_string()),
1✔
1138
            backup: None,
1✔
1139
        };
1✔
1140
        soundslice.normalize_paths(Path::new("./"))?;
1✔
1141

1142
        let temp_dir = tempfile::tempdir()?;
1✔
1143
        let temp_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1✔
1144
        let soundslice = ExerciseAsset::SoundSliceAsset {
1✔
1145
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1✔
1146
            description: Some("Test".to_string()),
1✔
1147
            backup: Some(temp_file.path().as_os_str().to_str().unwrap().to_string()),
1✔
1148
        };
1✔
1149
        soundslice.normalize_paths(temp_dir.path())?;
1✔
1150
        Ok(())
1✔
1151
    }
1✔
1152

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

1163
        let soundslice = ExerciseAsset::SoundSliceAsset {
1✔
1164
            link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
1✔
1165
            description: Some("Test".to_string()),
1✔
1166
            backup: Some("./bad_file".to_string()),
1✔
1167
        };
1✔
1168
        assert!(!soundslice.verify_paths(Path::new("./"))?);
1✔
1169
        Ok(())
1✔
1170
    }
1✔
1171

1172
    /// Verifies the `VerifyPaths` trait works for a flashcard asset.
1173
    #[test]
1174
    fn verify_flashcard_assets() -> Result<()> {
1✔
1175
        // Verify a flashcard with no back.
1176
        let temp_dir = tempfile::tempdir()?;
1✔
1177
        let front_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1✔
1178
        let flashcard_asset = ExerciseAsset::FlashcardAsset {
1✔
1179
            front_path: front_file.path().as_os_str().to_str().unwrap().to_string(),
1✔
1180
            back_path: None,
1✔
1181
        };
1✔
1182
        assert!(flashcard_asset.verify_paths(temp_dir.path())?);
1✔
1183

1184
        // Verify a flashcard with front and back.
1185
        let back_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1✔
1186
        let flashcard_asset = ExerciseAsset::FlashcardAsset {
1✔
1187
            front_path: front_file.path().as_os_str().to_str().unwrap().to_string(),
1✔
1188
            back_path: Some(back_file.path().as_os_str().to_str().unwrap().to_string()),
1✔
1189
        };
1✔
1190
        assert!(flashcard_asset.verify_paths(temp_dir.path())?);
1✔
1191
        Ok(())
1✔
1192
    }
1✔
1193

1194
    /// Verifies the `Display` trait for each unit type.
1195
    #[test]
1196
    fn unit_type_display() {
1✔
1197
        assert_eq!("Course", UnitType::Course.to_string());
1✔
1198
        assert_eq!("Lesson", UnitType::Lesson.to_string());
1✔
1199
        assert_eq!("Exercise", UnitType::Exercise.to_string());
1✔
1200
    }
1✔
1201

1202
    /// Verifies that normalizing a path works with the path to a valid file.
1203
    #[test]
1204
    fn normalize_good_path() -> Result<()> {
1✔
1205
        let temp_dir = tempfile::tempdir()?;
1✔
1206
        let temp_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
1✔
1207
        let temp_file_path = temp_file.path().to_str().unwrap();
1✔
1208
        let normalized_path = normalize_path(temp_dir.path(), temp_file_path)?;
1✔
1209
        assert_eq!(
1✔
1210
            temp_dir.path().join(temp_file_path).to_str().unwrap(),
1✔
1211
            normalized_path
1✔
1212
        );
1✔
1213
        Ok(())
1✔
1214
    }
1✔
1215

1216
    /// Verifies that normalizing an absolute path returns the original path.
1217
    #[test]
1218
    fn normalize_absolute_path() {
1✔
1219
        let normalized_path = normalize_path(Path::new("/working/dir"), "/absolute/path").unwrap();
1✔
1220
        assert_eq!("/absolute/path", normalized_path,);
1✔
1221
    }
1✔
1222

1223
    /// Verifies that normalizing a path fails with the path to a missing file.
1224
    #[test]
1225
    fn normalize_bad_path() -> Result<()> {
1✔
1226
        let temp_dir = tempfile::tempdir()?;
1✔
1227
        let temp_file_path = "missing_file";
1✔
1228
        assert!(normalize_path(temp_dir.path(), temp_file_path).is_err());
1✔
1229
        Ok(())
1✔
1230
    }
1✔
1231

1232
    /// Verifies the default scheduler options are valid.
1233
    #[test]
1234
    fn valid_default_scheduler_options() {
1✔
1235
        let options = SchedulerOptions::default();
1✔
1236
        assert!(options.verify().is_ok());
1✔
1237
    }
1✔
1238

1239
    /// Verifies scheduler options with a batch size of 0 are invalid.
1240
    #[test]
1241
    fn scheduler_options_invalid_batch_size() {
1✔
1242
        let options = SchedulerOptions {
1✔
1243
            batch_size: 0,
1✔
1244
            ..Default::default()
1✔
1245
        };
1✔
1246
        assert!(options.verify().is_err());
1✔
1247
    }
1✔
1248

1249
    /// Verifies scheduler options with an invalid mastered window range are invalid.
1250
    #[test]
1251
    fn scheduler_options_invalid_mastered_window() {
1✔
1252
        let mut options = SchedulerOptions::default();
1✔
1253
        options.mastered_window_opts.range.1 = 4.9;
1✔
1254
        assert!(options.verify().is_err());
1✔
1255
    }
1✔
1256

1257
    /// Verifies scheduler options with an invalid new window range are invalid.
1258
    #[test]
1259
    fn scheduler_options_invalid_new_window() {
1✔
1260
        let mut options = SchedulerOptions::default();
1✔
1261
        options.new_window_opts.range.0 = 0.1;
1✔
1262
        assert!(options.verify().is_err());
1✔
1263
    }
1✔
1264

1265
    /// Verifies that scheduler options with a gap in the windows are invalid.
1266
    #[test]
1267
    fn scheduler_options_gap_in_windows() {
1✔
1268
        let mut options = SchedulerOptions::default();
1✔
1269
        options.new_window_opts.range.1 -= 0.1;
1✔
1270
        assert!(options.verify().is_err());
1✔
1271

1272
        let mut options = SchedulerOptions::default();
1✔
1273
        options.target_window_opts.range.1 -= 0.1;
1✔
1274
        assert!(options.verify().is_err());
1✔
1275

1276
        let mut options = SchedulerOptions::default();
1✔
1277
        options.current_window_opts.range.1 -= 0.1;
1✔
1278
        assert!(options.verify().is_err());
1✔
1279

1280
        let mut options = SchedulerOptions::default();
1✔
1281
        options.easy_window_opts.range.1 -= 0.1;
1✔
1282
        assert!(options.verify().is_err());
1✔
1283
    }
1✔
1284

1285
    /// Verifies that scheduler options with a percentage sum other than 1 are invalid.
1286
    #[test]
1287
    fn scheduler_options_invalid_percentage_sum() {
1✔
1288
        let mut options = SchedulerOptions::default();
1✔
1289
        options.target_window_opts.percentage -= 0.1;
1✔
1290
        assert!(options.verify().is_err());
1✔
1291
    }
1✔
1292

1293
    /// Verifies that valid passing score options are recognized as such.
1294
    #[test]
1295
    fn verify_passing_score_options() {
1✔
1296
        let options = PassingScoreOptions::default();
1✔
1297
        assert!(options.verify().is_ok());
1✔
1298

1299
        let options = PassingScoreOptions::ConstantScore(3.50);
1✔
1300
        assert!(options.verify().is_ok());
1✔
1301
    }
1✔
1302

1303
    /// Verifies that invalid passing score options are recognized as such.
1304
    #[test]
1305
    fn verify_passing_score_options_invalid() {
1✔
1306
        let options = PassingScoreOptions::ConstantScore(-1.0);
1✔
1307
        assert!(options.verify().is_err());
1✔
1308

1309
        let options = PassingScoreOptions::ConstantScore(6.0);
1✔
1310
        assert!(options.verify().is_err());
1✔
1311

1312
        let options = PassingScoreOptions::IncreasingScore {
1✔
1313
            starting_score: -1.0,
1✔
1314
            step_size: 0.0,
1✔
1315
            max_steps: 0,
1✔
1316
        };
1✔
1317
        assert!(options.verify().is_err());
1✔
1318

1319
        let options = PassingScoreOptions::IncreasingScore {
1✔
1320
            starting_score: 6.0,
1✔
1321
            step_size: 0.0,
1✔
1322
            max_steps: 0,
1✔
1323
        };
1✔
1324
        assert!(options.verify().is_err());
1✔
1325

1326
        let options = PassingScoreOptions::IncreasingScore {
1✔
1327
            starting_score: 3.50,
1✔
1328
            step_size: -1.0,
1✔
1329
            max_steps: 0,
1✔
1330
        };
1✔
1331
        assert!(options.verify().is_err());
1✔
1332
    }
1✔
1333

1334
    /// Verifies that the passing score is computed correctly.
1335
    #[test]
1336
    fn compute_passing_score() {
1✔
1337
        let options = PassingScoreOptions::ConstantScore(3.50);
1✔
1338
        assert_eq!(options.compute_score(0), 3.50);
1✔
1339
        assert_eq!(options.compute_score(1), 3.50);
1✔
1340
        assert_eq!(options.compute_score(2), 3.50);
1✔
1341
        // Clone the score for code coverage.
1342
        assert_eq!(options, options.clone());
1✔
1343

1344
        let options = PassingScoreOptions::default();
1✔
1345
        assert_eq!(options.compute_score(0), 3.50);
1✔
1346
        assert_eq!(options.compute_score(1), 3.51);
1✔
1347
        assert_eq!(options.compute_score(2), 3.52);
1✔
1348
        assert_eq!(options.compute_score(5), 3.55);
1✔
1349
        assert_eq!(options.compute_score(25), 3.75);
1✔
1350
        assert_eq!(options.compute_score(50), 3.75);
1✔
1351
        // Clone the score for code coverage.
1352
        assert_eq!(options, options.clone());
1✔
1353
    }
1✔
1354

1355
    /// Verifies that the default exercise type is Procedural. Written to satisfy code coverage.
1356
    #[test]
1357
    fn default_exercise_type() {
1✔
1358
        let exercise_type = ExerciseType::default();
1✔
1359
        assert_eq!(exercise_type, ExerciseType::Procedural);
1✔
1360
    }
1✔
1361

1362
    /// Verifies the clone method for the `RepositoryMetadata` struct. Written to satisfy code
1363
    /// coverage.
1364
    #[test]
1365
    fn repository_metadata_clone() {
1✔
1366
        let metadata = RepositoryMetadata {
1✔
1367
            id: "id".to_string(),
1✔
1368
            url: "url".to_string(),
1✔
1369
        };
1✔
1370
        assert_eq!(metadata, metadata.clone());
1✔
1371
    }
1✔
1372

1373
    /// Verifies the clone method for the `UserPreferences` struct. Written to satisfy code
1374
    /// coverage.
1375
    #[test]
1376
    fn user_preferences_clone() {
1✔
1377
        let preferences = UserPreferences {
1✔
1378
            transcription: Some(TranscriptionPreferences {
1✔
1379
                instruments: vec![],
1✔
1380
                download_path: Some("/a/b/c".to_owned()),
1✔
1381
                download_path_alias: Some("alias".to_owned()),
1✔
1382
            }),
1✔
1383
            scheduler: Some(SchedulerPreferences {
1✔
1384
                batch_size: Some(10),
1✔
1385
            }),
1✔
1386
            ignored_paths: vec!["courses/".to_owned()],
1✔
1387
        };
1✔
1388
        assert_eq!(preferences, preferences.clone());
1✔
1389
    }
1✔
1390

1391
    /// Verifies the clone method for the `ExerciseTrial` struct. Written to satisfy code coverage.
1392
    #[test]
1393
    fn exercise_trial_clone() {
1✔
1394
        let trial = ExerciseTrial {
1✔
1395
            score: 5.0,
1✔
1396
            timestamp: 1,
1✔
1397
        };
1✔
1398
        assert_eq!(trial, trial.clone());
1✔
1399
    }
1✔
1400
}
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