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

trane-project / trane / 12735888711

12 Jan 2025 06:06PM UTC coverage: 92.973% (-6.9%) from 99.866%
12735888711

Pull #319

github

web-flow
Merge 2bba403ef into f4478d7eb
Pull Request #319: Update to rust 1.84

69 of 77 new or added lines in 18 files covered. (89.61%)

236 existing lines in 13 files now uncovered.

9672 of 10403 relevant lines covered (92.97%)

87046.61 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
pub mod course_generator;
6
pub mod filter;
7
pub mod music;
8

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

28,889✔
162
    Ok(working_dir
28,889✔
163
        .join(path)
28,889✔
164
        .canonicalize()?
28,889✔
165
        .to_str()
28,888✔
166
        .unwrap_or(path_str)
28,888✔
167
        .to_string())
28,888✔
168
}
29,052✔
169

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

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

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

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

202
    /// An asset containing its content as a string.
203
    InlinedAsset {
204
        /// The content of the asset.
205
        content: String,
206
    },
207

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

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

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

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

253
    /// The configuration for generating a literacy course.
254
    Literacy(LiteracyConfig),
255

256
    /// The configuration for generating a music piece course.
257
    MusicPiece(MusicPieceConfig),
258

259
    /// The configuration for generating a transcription course.
260
    Transcription(TranscriptionConfig),
261
}
262
//>@course-generator
263

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

270
    /// Updated course metadata. If None, the existing metadata is used.
271
    pub updated_metadata: Option<BTreeMap<String, Vec<String>>>,
272

273
    /// Updated course instructions. If None, the existing instructions are used.
274
    pub updated_instructions: Option<BasicAsset>,
275
}
276

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

428
impl GetUnitType for CourseManifest {
429
    fn get_unit_type(&self) -> UnitType {
1✔
430
        UnitType::Course
1✔
431
    }
1✔
432
}
433

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

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

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

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

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

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

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

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

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

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

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

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

532
impl GetUnitType for LessonManifest {
533
    fn get_unit_type(&self) -> UnitType {
1✔
534
        UnitType::Lesson
1✔
535
    }
1✔
536
}
537

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

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

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

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

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

575
    /// An asset representing a literacy exercise.
576
    LiteracyAsset {
577
        /// The type of the lesson.
578
        lesson_type: LiteracyLesson,
579

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

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

589
    /// An asset which stores a link to a SoundSlice.
590
    SoundSliceAsset {
591
        /// The link to the SoundSlice asset.
592
        link: String,
593

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

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

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

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

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

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

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

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

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

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

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

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

736
    /// The asset containing the exercise itself.
737
    pub exercise_asset: ExerciseAsset,
738
}
739

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

748
impl VerifyPaths for ExerciseManifest {
749
    fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
12,677✔
750
        self.exercise_asset.verify_paths(working_dir)
12,677✔
751
    }
12,677✔
752
}
753

754
impl GetUnitType for ExerciseManifest {
755
    fn get_unit_type(&self) -> UnitType {
1✔
756
        UnitType::Exercise
1✔
757
    }
1✔
758
}
759

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

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

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

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

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

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

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

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

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

861
impl MasteryWindow {
862
    /// Returns whether the given score falls within this window.
863
    #[must_use]
864
    pub fn in_window(&self, score: f32) -> bool {
11,027,110✔
865
        // Handle the special case of the window containing the maximum score. Scores greater than
11,027,110✔
866
        // 5.0 are allowed because float comparison is not exact.
11,027,110✔
867
        if self.range.1 >= 5.0 && score >= 5.0 {
11,027,110✔
868
            return true;
2,124,282✔
869
        }
8,902,828✔
870

8,902,828✔
871
        // Return true if the score falls within the range `[range.0, range.1)`.
8,902,828✔
872
        self.range.0 <= score && score < self.range.1
8,902,828✔
873
    }
11,027,110✔
874
}
875

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

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

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

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

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

899
    /// The options for the mastered mastery window. That is, the window of exercises that the user
900
    /// has properly mastered.
901
    pub mastered_window_opts: MasteryWindow,
902

903
    /// The minimum average score of a unit required to move on to its dependents.
904
    pub passing_score: PassingScoreOptions,
905

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

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

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

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

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

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

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

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

72✔
970
        Ok(())
72✔
971
    }
80✔
972
}
973

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

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

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

1025
    /// The URL of the repository.
1026
    pub url: String,
1027
}
1028

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

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

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

1053
#[cfg(test)]
1054
mod test {
1055
    use crate::data::*;
1056

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

1269
        let mut options = SchedulerOptions::default();
1✔
1270
        options.target_window_opts.range.1 -= 0.1;
1✔
1271
        assert!(options.verify().is_err());
1✔
1272

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

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

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

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

1296
        let options = PassingScoreOptions::ConstantScore(3.50);
1✔
1297
        assert!(options.verify().is_ok());
1✔
1298
    }
1✔
1299

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

1306
        let options = PassingScoreOptions::ConstantScore(6.0);
1✔
1307
        assert!(options.verify().is_err());
1✔
1308

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

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

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

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

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

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

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

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

1388
    /// Verifies the clone method for the `ExerciseTrial` struct. Written to satisfy code coverage.
1389
    #[test]
1390
    fn exercise_trial_clone() {
1✔
1391
        let trial = ExerciseTrial {
1✔
1392
            score: 5.0,
1✔
1393
            timestamp: 1,
1✔
1394
        };
1✔
1395
        assert_eq!(trial, trial.clone());
1✔
1396
    }
1✔
1397
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc