pub mod course_generator;
pub mod filter;
use anyhow::{Result, bail};
use derive_builder::Builder;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::Path};
use ustr::Ustr;
use crate::data::course_generator::{
knowledge_base::KnowledgeBaseConfig,
literacy::{LiteracyConfig, LiteracyLessonType},
music_piece::MusicPieceConfig,
transcription::{TranscriptionConfig, TranscriptionLink, TranscriptionPreferences},
};
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub enum MasteryScore {
One,
Two,
Three,
Four,
Five,
}
impl MasteryScore {
#[must_use]
pub fn float_score(&self) -> f32 {
match *self {
Self::One => 1.0,
Self::Two => 2.0,
Self::Three => 3.0,
Self::Four => 4.0,
Self::Five => 5.0,
}
}
}
impl TryFrom<MasteryScore> for f32 {
type Error = ();
fn try_from(score: MasteryScore) -> Result<f32, ()> {
Ok(score.float_score())
}
}
impl TryFrom<f32> for MasteryScore {
type Error = ();
fn try_from(score: f32) -> Result<MasteryScore, ()> {
if (score - 1.0_f32).abs() < f32::EPSILON {
Ok(MasteryScore::One)
} else if (score - 2.0_f32).abs() < f32::EPSILON {
Ok(MasteryScore::Two)
} else if (score - 3.0_f32).abs() < f32::EPSILON {
Ok(MasteryScore::Three)
} else if (score - 4.0_f32).abs() < f32::EPSILON {
Ok(MasteryScore::Four)
} else if (score - 5.0_f32).abs() < f32::EPSILON {
Ok(MasteryScore::Five)
} else {
Err(())
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ExerciseTrial {
pub score: f32,
pub timestamp: i64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct UnitReward {
pub unit_id: Ustr,
pub value: f32,
pub weight: f32,
pub timestamp: i64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub enum UnitType {
Exercise,
Lesson,
Course,
}
impl std::fmt::Display for UnitType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Exercise => "Exercise".fmt(f),
Self::Lesson => "Lesson".fmt(f),
Self::Course => "Course".fmt(f),
}
}
}
pub trait NormalizePaths
where
Self: Sized,
{
fn normalize_paths(&self, working_dir: &Path) -> Result<Self>;
}
fn normalize_path(working_dir: &Path, path_str: &str) -> Result<String> {
let path = Path::new(path_str);
if path.is_absolute() {
return Ok(path_str.to_string());
}
Ok(working_dir
.join(path)
.canonicalize()?
.to_str()
.unwrap_or(path_str)
.to_string())
}
pub trait VerifyPaths
where
Self: Sized,
{
fn verify_paths(&self, working_dir: &Path) -> Result<bool>;
}
pub trait GetMetadata {
fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>>;
}
pub trait GetUnitType {
fn get_unit_type(&self) -> UnitType;
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub enum BasicAsset {
MarkdownAsset {
path: String,
},
InlinedAsset {
content: String,
},
InlinedUniqueAsset {
content: Ustr,
},
}
impl NormalizePaths for BasicAsset {
fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
match &self {
BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => {
Ok(self.clone()) }
BasicAsset::MarkdownAsset { path } => {
let abs_path = normalize_path(working_dir, path)?;
Ok(BasicAsset::MarkdownAsset { path: abs_path })
}
}
}
}
impl VerifyPaths for BasicAsset {
fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
match &self {
BasicAsset::InlinedAsset { .. } | BasicAsset::InlinedUniqueAsset { .. } => Ok(true),
BasicAsset::MarkdownAsset { path } => {
let abs_path = working_dir.join(Path::new(path));
Ok(abs_path.exists())
}
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub enum CourseGenerator {
KnowledgeBase(KnowledgeBaseConfig),
Literacy(LiteracyConfig),
MusicPiece(MusicPieceConfig),
Transcription(TranscriptionConfig),
}
#[derive(Debug, PartialEq)]
pub struct GeneratedCourse {
pub lessons: Vec<(LessonManifest, Vec<ExerciseManifest>)>,
pub updated_metadata: Option<BTreeMap<String, Vec<String>>>,
pub updated_instructions: Option<BasicAsset>,
}
pub trait GenerateManifests {
fn generate_manifests(
&self,
course_root: &Path,
course_manifest: &CourseManifest,
preferences: &UserPreferences,
) -> Result<GeneratedCourse>;
}
impl GenerateManifests for CourseGenerator {
fn generate_manifests(
&self,
course_root: &Path,
course_manifest: &CourseManifest,
preferences: &UserPreferences,
) -> Result<GeneratedCourse> {
match self {
CourseGenerator::KnowledgeBase(config) => {
config.generate_manifests(course_root, course_manifest, preferences)
}
CourseGenerator::Literacy(config) => {
config.generate_manifests(course_root, course_manifest, preferences)
}
CourseGenerator::MusicPiece(config) => {
config.generate_manifests(course_root, course_manifest, preferences)
}
CourseGenerator::Transcription(config) => {
config.generate_manifests(course_root, course_manifest, preferences)
}
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub enum ExerciseType {
Declarative,
#[default]
Procedural,
}
#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct CourseManifest {
#[builder(setter(into))]
pub id: Ustr,
#[builder(default)]
#[serde(default)]
pub name: String,
#[builder(default)]
#[serde(default)]
pub dependencies: Vec<Ustr>,
#[builder(default)]
#[serde(default)]
pub encompassed: Vec<(Ustr, f32)>,
#[builder(default)]
#[serde(default)]
pub superseded: Vec<Ustr>,
#[builder(default)]
#[serde(default)]
pub description: Option<String>,
#[builder(default)]
#[serde(default)]
pub authors: Option<Vec<String>>,
#[builder(default)]
#[serde(default)]
pub metadata: Option<BTreeMap<String, Vec<String>>>,
#[builder(default)]
#[serde(default)]
pub course_material: Option<BasicAsset>,
#[builder(default)]
#[serde(default)]
pub course_instructions: Option<BasicAsset>,
#[builder(default)]
#[serde(default)]
pub generator_config: Option<CourseGenerator>,
}
impl NormalizePaths for CourseManifest {
fn normalize_paths(&self, working_directory: &Path) -> Result<Self> {
let mut clone = self.clone();
match &self.course_instructions {
None => (),
Some(asset) => {
clone.course_instructions = Some(asset.normalize_paths(working_directory)?);
}
}
match &self.course_material {
None => (),
Some(asset) => clone.course_material = Some(asset.normalize_paths(working_directory)?),
}
Ok(clone)
}
}
impl VerifyPaths for CourseManifest {
fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
let instructions_exist = match &self.course_instructions {
None => true,
Some(asset) => asset.verify_paths(working_dir)?,
};
let material_exists = match &self.course_material {
None => true,
Some(asset) => asset.verify_paths(working_dir)?,
};
Ok(instructions_exist && material_exists)
}
}
impl GetMetadata for CourseManifest {
fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>> {
self.metadata.as_ref()
}
}
impl GetUnitType for CourseManifest {
fn get_unit_type(&self) -> UnitType {
UnitType::Course
}
}
#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct LessonManifest {
#[builder(setter(into))]
pub id: Ustr,
#[builder(default)]
#[serde(default)]
pub dependencies: Vec<Ustr>,
#[builder(default)]
#[serde(default)]
pub encompassed: Vec<(Ustr, f32)>,
#[builder(default)]
#[serde(default)]
pub superseded: Vec<Ustr>,
#[builder(setter(into))]
pub course_id: Ustr,
#[builder(default)]
#[serde(default)]
pub name: String,
#[builder(default)]
#[serde(default)]
pub description: Option<String>,
#[builder(default)]
#[serde(default)]
pub metadata: Option<BTreeMap<String, Vec<String>>>,
#[builder(default)]
#[serde(default)]
pub lesson_material: Option<BasicAsset>,
#[builder(default)]
#[serde(default)]
pub lesson_instructions: Option<BasicAsset>,
}
impl NormalizePaths for LessonManifest {
fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
let mut clone = self.clone();
if let Some(asset) = &self.lesson_instructions {
clone.lesson_instructions = Some(asset.normalize_paths(working_dir)?);
}
if let Some(asset) = &self.lesson_material {
clone.lesson_material = Some(asset.normalize_paths(working_dir)?);
}
Ok(clone)
}
}
impl VerifyPaths for LessonManifest {
fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
let instruction_exists = match &self.lesson_instructions {
None => true,
Some(asset) => asset.verify_paths(working_dir)?,
};
let material_exists = match &self.lesson_material {
None => true,
Some(asset) => asset.verify_paths(working_dir)?,
};
Ok(instruction_exists && material_exists)
}
}
impl GetMetadata for LessonManifest {
fn get_metadata(&self) -> Option<&BTreeMap<String, Vec<String>>> {
self.metadata.as_ref()
}
}
impl GetUnitType for LessonManifest {
fn get_unit_type(&self) -> UnitType {
UnitType::Lesson
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub enum ExerciseAsset {
BasicAsset(BasicAsset),
FlashcardAsset {
front_path: String,
#[serde(default)]
back_path: Option<String>,
},
InlineFlashcardAsset {
front_content: String,
#[serde(default)]
back_content: Option<String>,
},
LiteracyAsset {
lesson_type: LiteracyLessonType,
#[serde(default)]
examples: Vec<String>,
#[serde(default)]
exceptions: Vec<String>,
},
SoundSliceAsset {
link: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
backup: Option<String>,
},
TranscriptionAsset {
#[serde(default)]
content: String,
#[serde(default)]
external_link: Option<TranscriptionLink>,
},
}
impl NormalizePaths for ExerciseAsset {
fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
match &self {
ExerciseAsset::BasicAsset(asset) => Ok(ExerciseAsset::BasicAsset(
asset.normalize_paths(working_dir)?,
)),
ExerciseAsset::FlashcardAsset {
front_path,
back_path,
} => {
let abs_front_path = normalize_path(working_dir, front_path)?;
let abs_back_path = if let Some(back_path) = back_path {
Some(normalize_path(working_dir, back_path)?)
} else {
None };
Ok(ExerciseAsset::FlashcardAsset {
front_path: abs_front_path,
back_path: abs_back_path,
})
}
ExerciseAsset::InlineFlashcardAsset { .. } => Ok(self.clone()), ExerciseAsset::LiteracyAsset { .. } | ExerciseAsset::TranscriptionAsset { .. } => {
Ok(self.clone()) }
ExerciseAsset::SoundSliceAsset {
link,
description,
backup,
} => match backup {
None => Ok(self.clone()),
Some(path) => {
let abs_path = normalize_path(working_dir, path)?;
Ok(ExerciseAsset::SoundSliceAsset {
link: link.clone(),
description: description.clone(),
backup: Some(abs_path),
})
}
},
}
}
}
impl VerifyPaths for ExerciseAsset {
fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
match &self {
ExerciseAsset::BasicAsset(asset) => asset.verify_paths(working_dir),
ExerciseAsset::FlashcardAsset {
front_path,
back_path,
} => {
let front_abs_path = working_dir.join(Path::new(front_path));
if let Some(back_path) = back_path {
let back_abs_path = working_dir.join(Path::new(back_path));
Ok(front_abs_path.exists() && back_abs_path.exists())
} else {
Ok(front_abs_path.exists())
}
}
ExerciseAsset::InlineFlashcardAsset { .. } => Ok(true),
ExerciseAsset::LiteracyAsset { .. } | ExerciseAsset::TranscriptionAsset { .. } => {
Ok(true)
}
ExerciseAsset::SoundSliceAsset { backup, .. } => match backup {
None => Ok(true),
Some(path) => {
let abs_path = working_dir.join(Path::new(path));
Ok(abs_path.exists())
}
},
}
}
}
#[derive(Builder, Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct ExerciseManifest {
#[builder(setter(into))]
pub id: Ustr,
#[builder(setter(into))]
pub lesson_id: Ustr,
#[builder(setter(into))]
pub course_id: Ustr,
#[builder(default)]
#[serde(default)]
pub name: String,
#[builder(default)]
#[serde(default)]
pub description: Option<String>,
#[builder(default)]
#[serde(default)]
pub exercise_type: ExerciseType,
pub exercise_asset: ExerciseAsset,
}
impl NormalizePaths for ExerciseManifest {
fn normalize_paths(&self, working_dir: &Path) -> Result<Self> {
let mut clone = self.clone();
clone.exercise_asset = clone.exercise_asset.normalize_paths(working_dir)?;
Ok(clone)
}
}
impl VerifyPaths for ExerciseManifest {
fn verify_paths(&self, working_dir: &Path) -> Result<bool> {
self.exercise_asset.verify_paths(working_dir)
}
}
impl GetUnitType for ExerciseManifest {
fn get_unit_type(&self) -> UnitType {
UnitType::Exercise
}
}
pub const FULL_CANDIDATES_SCORE: f32 = 4.0;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PassingScoreOptions {
pub min_fraction: f32,
pub min_score: f32,
pub min_avg_trials: f32,
}
impl Default for PassingScoreOptions {
fn default() -> Self {
PassingScoreOptions {
min_score: 3.0,
min_fraction: 0.5,
min_avg_trials: 1.8,
}
}
}
impl PassingScoreOptions {
pub fn verify(&self) -> Result<()> {
if self.min_score < 0.0 || self.min_score >= FULL_CANDIDATES_SCORE {
bail!("invalid minimum score: {}", self.min_score);
}
if self.min_fraction < 0.0 || self.min_fraction > 1.0 {
bail!("invalid minimum fraction: {}", self.min_fraction);
}
if self.min_avg_trials < 1.0 {
bail!("invalid minimum average trials: {}", self.min_avg_trials);
}
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct MasteryWindow {
pub percentage: f32,
pub range: (f32, f32),
}
impl MasteryWindow {
#[must_use]
pub fn in_window(&self, score: f32) -> bool {
if self.range.1 >= 5.0 && score >= 5.0 {
return true;
}
self.range.0 <= score && score < self.range.1
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SchedulerOptions {
pub batch_size: usize,
pub relearn_fraction: f32,
pub new_window_opts: MasteryWindow,
pub target_window_opts: MasteryWindow,
pub current_window_opts: MasteryWindow,
pub easy_window_opts: MasteryWindow,
pub mastered_window_opts: MasteryWindow,
pub passing_score: PassingScoreOptions,
pub superseding_score: f32,
pub num_trials: u32,
pub num_rewards: u32,
pub max_lessons_in_progress: usize,
}
impl SchedulerOptions {
#[must_use]
fn float_equals(f1: f32, f2: f32) -> bool {
(f1 - f2).abs() < f32::EPSILON
}
pub fn verify(&self) -> Result<()> {
if self.batch_size == 0 {
bail!("invalid scheduler options: batch_size must be greater than 0");
}
if self.relearn_fraction < 0.0 || self.relearn_fraction > 1.0 {
bail!("invalid scheduler options: relearn_fraction must be between 0.0 and 1.0");
}
self.passing_score.verify()?;
if !Self::float_equals(
self.mastered_window_opts.percentage
+ self.easy_window_opts.percentage
+ self.current_window_opts.percentage
+ self.target_window_opts.percentage
+ self.new_window_opts.percentage,
1.0,
) {
bail!(
"invalid scheduler options: the sum of the percentages of the mastery windows \
must be 1.0"
);
}
if !Self::float_equals(self.new_window_opts.range.0, 0.0) {
bail!("invalid scheduler options: the new window's range must start at 0.0");
}
if !Self::float_equals(self.mastered_window_opts.range.1, 5.0) {
bail!("invalid scheduler options: the mastered window's range must end at 5.0");
}
if !Self::float_equals(
self.new_window_opts.range.1,
self.target_window_opts.range.0,
) || !Self::float_equals(
self.target_window_opts.range.1,
self.current_window_opts.range.0,
) || !Self::float_equals(
self.current_window_opts.range.1,
self.easy_window_opts.range.0,
) || !Self::float_equals(
self.easy_window_opts.range.1,
self.mastered_window_opts.range.0,
) {
bail!("invalid scheduler options: there must be no gaps in the mastery windows");
}
if self.max_lessons_in_progress == 0 {
bail!("invalid scheduler options: max_lessons_in_progress must be greater than 0");
}
Ok(())
}
}
impl Default for SchedulerOptions {
fn default() -> Self {
SchedulerOptions {
batch_size: 50,
relearn_fraction: 0.1,
new_window_opts: MasteryWindow {
percentage: 0.2,
range: (0.0, 0.1),
},
target_window_opts: MasteryWindow {
percentage: 0.2,
range: (0.1, 2.5),
},
current_window_opts: MasteryWindow {
percentage: 0.3,
range: (2.5, 3.75),
},
easy_window_opts: MasteryWindow {
percentage: 0.2,
range: (3.75, 4.5),
},
mastered_window_opts: MasteryWindow {
percentage: 0.1,
range: (4.5, 5.0),
},
passing_score: PassingScoreOptions::default(),
superseding_score: 4.0,
num_trials: 15,
num_rewards: 10,
max_lessons_in_progress: 10,
}
}
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct SchedulerPreferences {
#[serde(default)]
pub batch_size: Option<usize>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct RepositoryMetadata {
pub id: String,
pub url: String,
}
#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)]
pub struct UserPreferences {
#[serde(default)]
pub transcription: Option<TranscriptionPreferences>,
#[serde(default)]
pub scheduler: Option<SchedulerPreferences>,
#[serde(default)]
pub ignored_paths: Vec<String>,
}
#[cfg(test)]
#[cfg_attr(coverage, coverage(off))]
mod test {
use crate::data::*;
#[test]
fn score_to_float() {
assert_eq!(1.0, MasteryScore::One.float_score());
assert_eq!(2.0, MasteryScore::Two.float_score());
assert_eq!(3.0, MasteryScore::Three.float_score());
assert_eq!(4.0, MasteryScore::Four.float_score());
assert_eq!(5.0, MasteryScore::Five.float_score());
assert_eq!(1.0, f32::try_from(MasteryScore::One).unwrap());
assert_eq!(2.0, f32::try_from(MasteryScore::Two).unwrap());
assert_eq!(3.0, f32::try_from(MasteryScore::Three).unwrap());
assert_eq!(4.0, f32::try_from(MasteryScore::Four).unwrap());
assert_eq!(5.0, f32::try_from(MasteryScore::Five).unwrap());
}
#[test]
fn float_to_score() {
assert_eq!(MasteryScore::One, MasteryScore::try_from(1.0).unwrap());
assert_eq!(MasteryScore::Two, MasteryScore::try_from(2.0).unwrap());
assert_eq!(MasteryScore::Three, MasteryScore::try_from(3.0).unwrap());
assert_eq!(MasteryScore::Four, MasteryScore::try_from(4.0).unwrap());
assert_eq!(MasteryScore::Five, MasteryScore::try_from(5.0).unwrap());
assert!(MasteryScore::try_from(-1.0).is_err());
assert!(MasteryScore::try_from(0.0).is_err());
assert!(MasteryScore::try_from(3.5).is_err());
assert!(MasteryScore::try_from(5.1).is_err());
}
#[test]
fn get_unit_type() {
assert_eq!(
UnitType::Course,
CourseManifestBuilder::default()
.id("test")
.name("Test".to_string())
.dependencies(vec![])
.build()
.unwrap()
.get_unit_type()
);
assert_eq!(
UnitType::Lesson,
LessonManifestBuilder::default()
.id("test")
.course_id("test")
.name("Test".to_string())
.dependencies(vec![])
.build()
.unwrap()
.get_unit_type()
);
assert_eq!(
UnitType::Exercise,
ExerciseManifestBuilder::default()
.id("test")
.course_id("test")
.lesson_id("test")
.name("Test".to_string())
.exercise_type(ExerciseType::Procedural)
.exercise_asset(ExerciseAsset::FlashcardAsset {
front_path: "front.png".to_string(),
back_path: Some("back.png".to_string()),
})
.build()
.unwrap()
.get_unit_type()
);
}
#[test]
fn soundslice_normalize_paths() -> Result<()> {
let soundslice = ExerciseAsset::SoundSliceAsset {
link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
description: Some("Test".to_string()),
backup: None,
};
soundslice.normalize_paths(Path::new("./"))?;
let temp_dir = tempfile::tempdir()?;
let temp_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
let soundslice = ExerciseAsset::SoundSliceAsset {
link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
description: Some("Test".to_string()),
backup: Some(temp_file.path().as_os_str().to_str().unwrap().to_string()),
};
soundslice.normalize_paths(temp_dir.path())?;
Ok(())
}
#[test]
fn soundslice_verify_paths() -> Result<()> {
let soundslice = ExerciseAsset::SoundSliceAsset {
link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
description: Some("Test".to_string()),
backup: None,
};
assert!(soundslice.verify_paths(Path::new("./"))?);
let soundslice = ExerciseAsset::SoundSliceAsset {
link: "https://www.soundslice.com/slices/QfZcc/".to_string(),
description: Some("Test".to_string()),
backup: Some("./bad_file".to_string()),
};
assert!(!soundslice.verify_paths(Path::new("./"))?);
Ok(())
}
#[test]
fn flashcard_verify_paths() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let front_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
let flashcard_asset = ExerciseAsset::FlashcardAsset {
front_path: front_file.path().as_os_str().to_str().unwrap().to_string(),
back_path: None,
};
assert!(flashcard_asset.verify_paths(temp_dir.path())?);
let back_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
let flashcard_asset = ExerciseAsset::FlashcardAsset {
front_path: front_file.path().as_os_str().to_str().unwrap().to_string(),
back_path: Some(back_file.path().as_os_str().to_str().unwrap().to_string()),
};
assert!(flashcard_asset.verify_paths(temp_dir.path())?);
let flashcard_asset = ExerciseAsset::InlineFlashcardAsset {
front_content: "Front".to_string(),
back_content: Some("Back".to_string()),
};
assert!(flashcard_asset.verify_paths(temp_dir.path())?);
Ok(())
}
#[test]
fn literacy_verify_paths() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let literacy_asset = ExerciseAsset::LiteracyAsset {
lesson_type: LiteracyLessonType::Reading,
examples: vec!["C".to_string(), "D".to_string()],
exceptions: vec!["E".to_string()],
};
assert!(literacy_asset.verify_paths(temp_dir.path())?);
Ok(())
}
#[test]
fn unit_type_display() {
assert_eq!("Course", UnitType::Course.to_string());
assert_eq!("Lesson", UnitType::Lesson.to_string());
assert_eq!("Exercise", UnitType::Exercise.to_string());
}
#[test]
fn normalize_good_path() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let temp_file = tempfile::NamedTempFile::new_in(temp_dir.path())?;
let temp_file_path = temp_file.path().to_str().unwrap();
let normalized_path = normalize_path(temp_dir.path(), temp_file_path)?;
assert_eq!(
temp_dir.path().join(temp_file_path).to_str().unwrap(),
normalized_path
);
Ok(())
}
#[test]
fn normalize_absolute_path() {
let normalized_path = normalize_path(Path::new("/working/dir"), "/absolute/path").unwrap();
assert_eq!("/absolute/path", normalized_path,);
}
#[test]
fn normalize_bad_path() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let temp_file_path = "missing_file";
assert!(normalize_path(temp_dir.path(), temp_file_path).is_err());
Ok(())
}
#[test]
fn valid_default_scheduler_options() {
let options = SchedulerOptions::default();
assert!(options.verify().is_ok());
}
#[test]
fn scheduler_options_invalid_batch_size() {
let options = SchedulerOptions {
batch_size: 0,
..Default::default()
};
assert!(options.verify().is_err());
}
#[test]
fn scheduler_options_invalid_relearn_fraction() {
let options = SchedulerOptions {
relearn_fraction: -0.1,
..Default::default()
};
assert!(options.verify().is_err());
let options = SchedulerOptions {
relearn_fraction: 1.1,
..Default::default()
};
assert!(options.verify().is_err());
}
#[test]
fn scheduler_options_invalid_mastered_window() {
let mut options = SchedulerOptions::default();
options.mastered_window_opts.range.1 = 4.9;
assert!(options.verify().is_err());
}
#[test]
fn scheduler_options_invalid_new_window() {
let mut options = SchedulerOptions::default();
options.new_window_opts.range.0 = 0.1;
assert!(options.verify().is_err());
}
#[test]
fn scheduler_options_gap_in_windows() {
let mut options = SchedulerOptions::default();
options.new_window_opts.range.1 -= 0.1;
assert!(options.verify().is_err());
let mut options = SchedulerOptions::default();
options.target_window_opts.range.1 -= 0.1;
assert!(options.verify().is_err());
let mut options = SchedulerOptions::default();
options.current_window_opts.range.1 -= 0.1;
assert!(options.verify().is_err());
let mut options = SchedulerOptions::default();
options.easy_window_opts.range.1 -= 0.1;
assert!(options.verify().is_err());
}
#[test]
fn scheduler_options_invalid_percentage_sum() {
let mut options = SchedulerOptions::default();
options.target_window_opts.percentage -= 0.1;
assert!(options.verify().is_err());
}
#[test]
fn scheduler_options_invalid_passing_score() {
let mut options = SchedulerOptions::default();
options.passing_score.min_fraction = -0.1;
assert!(options.verify().is_err());
}
#[test]
fn verify_passing_score_options() {
let options = PassingScoreOptions::default();
assert!(options.verify().is_ok());
let options = PassingScoreOptions {
min_score: 3.75,
min_fraction: 0.75,
min_avg_trials: 1.0,
};
assert!(options.verify().is_ok());
}
#[test]
fn verify_passing_score_options_invalid() {
let options = PassingScoreOptions {
min_score: -1.0,
min_fraction: 0.2,
min_avg_trials: 1.0,
};
assert!(options.verify().is_err());
let options = PassingScoreOptions {
min_score: 4.6,
min_fraction: 0.2,
min_avg_trials: 1.0,
};
assert!(options.verify().is_err());
let options = PassingScoreOptions {
min_score: 3.0,
min_fraction: -0.1,
min_avg_trials: 1.0,
};
assert!(options.verify().is_err());
let options = PassingScoreOptions {
min_score: 3.0,
min_fraction: 1.1,
min_avg_trials: 1.0,
};
assert!(options.verify().is_err());
let options = PassingScoreOptions {
min_score: 3.0,
min_fraction: 0.2,
min_avg_trials: -0.1,
};
assert!(options.verify().is_err());
}
#[test]
fn verify_scheduler_options_zero_max_lessons() {
let options = SchedulerOptions {
max_lessons_in_progress: 0,
..Default::default()
};
assert!(options.verify().is_err());
}
#[test]
fn default_exercise_type() {
let exercise_type = ExerciseType::default();
assert_eq!(exercise_type, ExerciseType::Procedural);
}
#[test]
fn repository_metadata_clone() {
let metadata = RepositoryMetadata {
id: "id".to_string(),
url: "url".to_string(),
};
assert_eq!(metadata, metadata.clone());
}
#[test]
fn user_preferences_clone() {
let preferences = UserPreferences {
transcription: Some(TranscriptionPreferences {
instruments: vec![],
download_path: Some("/a/b/c".to_owned()),
download_path_alias: Some("alias".to_owned()),
}),
scheduler: Some(SchedulerPreferences {
batch_size: Some(10),
}),
ignored_paths: vec!["courses/".to_owned()],
};
assert_eq!(preferences, preferences.clone());
}
#[test]
fn exercise_trial_clone() {
let trial = ExerciseTrial {
score: 5.0,
timestamp: 1,
};
assert_eq!(trial, trial.clone());
}
#[test]
fn unit_reward_clone() {
let reward = UnitReward {
unit_id: Ustr::from("unit"),
timestamp: 1,
value: 1.0,
weight: 1.0,
};
assert_eq!(reward, reward.clone());
}
}