Dev #1
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coursebank"
|
name = "coursebank"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
rust-version = "1.75"
|
rust-version = "1.85"
|
||||||
description = "Author, assemble, administer, and analyze leveled course assessments from YAML item banks"
|
description = "Author, assemble, administer, and analyze leveled course assessments from YAML item banks"
|
||||||
authors = ["Alex Maldonado <alexm@scient.ing>"]
|
authors = ["Alex Maldonado <alexm@scient.ing>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -226,7 +226,8 @@ Assembling a form programmatically:
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use coursebank::assessment::{Blueprint, History};
|
use coursebank::assessment::Blueprint;
|
||||||
|
use coursebank::history::History;
|
||||||
use coursebank::date::Date;
|
use coursebank::date::Date;
|
||||||
use coursebank::{select, Catalog, Level};
|
use coursebank::{select, Catalog, Level};
|
||||||
|
|
||||||
|
|||||||
@@ -622,7 +622,7 @@ pub fn apply(plan: &Plan) -> Result<Vec<PathBuf>> {
|
|||||||
"{} — the bank changed since the plan was built; re-run calibration",
|
"{} — the bank changed since the plan was built; re-run calibration",
|
||||||
path.display()
|
path.display()
|
||||||
)),
|
)),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -794,12 +794,14 @@ mod tests {
|
|||||||
next.flags = vec![Flag::NegativeDiscrimination];
|
next.flags = vec![Flag::NegativeDiscrimination];
|
||||||
|
|
||||||
let diff = diff_calibration(Some(&previous), &next);
|
let diff = diff_calibration(Some(&previous), &next);
|
||||||
assert!(diff
|
assert!(
|
||||||
.iter()
|
diff.iter()
|
||||||
.any(|d| d.contains("flags added") && d.contains("negative_discrimination")));
|
.any(|d| d.contains("flags added") && d.contains("negative_discrimination"))
|
||||||
assert!(diff
|
);
|
||||||
.iter()
|
assert!(
|
||||||
.any(|d| d.contains("flags cleared") && d.contains("too_easy")));
|
diff.iter()
|
||||||
|
.any(|d| d.contains("flags cleared") && d.contains("too_easy"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+16
-15
@@ -197,11 +197,7 @@ impl ItemFit {
|
|||||||
// For the 3PL this reduces to the 2PL form when c = 0.
|
// For the 3PL this reduces to the 2PL form when c = 0.
|
||||||
let num = self.a * self.a * (p - c) * (p - c) * (1.0 - p);
|
let num = self.a * self.a * (p - c) * (p - c) * (1.0 - p);
|
||||||
let den = (1.0 - c) * (1.0 - c) * p;
|
let den = (1.0 - c) * (1.0 - c) * p;
|
||||||
if den <= 0.0 {
|
if den <= 0.0 { 0.0 } else { num / den }
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
num / den
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +420,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
|||||||
let mut a = vec![1.0f64; j_count];
|
let mut a = vec![1.0f64; j_count];
|
||||||
let mut b = vec![0.0f64; j_count];
|
let mut b = vec![0.0f64; j_count];
|
||||||
let mut c = vec![0.0f64; j_count];
|
let mut c = vec![0.0f64; j_count];
|
||||||
for j in 0..j_count {
|
for (j, b_j) in b.iter_mut().enumerate() {
|
||||||
let column = matrix.column(j);
|
let column = matrix.column(j);
|
||||||
let answered: Vec<u8> = column.into_iter().flatten().collect();
|
let answered: Vec<u8> = column.into_iter().flatten().collect();
|
||||||
if answered.is_empty() {
|
if answered.is_empty() {
|
||||||
@@ -434,7 +430,7 @@ pub fn fit(matrix: &Matrix, opts: &Options) -> Fit {
|
|||||||
let clamped = p.clamp(0.03, 0.97);
|
let clamped = p.clamp(0.03, 0.97);
|
||||||
// Inverse logistic of the p-value, which is the difficulty a Rasch model
|
// Inverse logistic of the p-value, which is the difficulty a Rasch model
|
||||||
// implies when discrimination is one.
|
// implies when discrimination is one.
|
||||||
b[j] = -(clamped / (1.0 - clamped)).ln();
|
*b_j = -(clamped / (1.0 - clamped)).ln();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut iterations = 0usize;
|
let mut iterations = 0usize;
|
||||||
@@ -1102,8 +1098,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rasch_fixes_discrimination_at_one() {
|
fn rasch_fixes_discrimination_at_one() {
|
||||||
let mut opts = Options::default();
|
let opts = Options {
|
||||||
opts.model = IrtModel::Rasch;
|
model: IrtModel::Rasch,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let f = fit(&ordered_matrix(), &opts);
|
let f = fit(&ordered_matrix(), &opts);
|
||||||
assert!(f.items.iter().all(|i| (i.a - 1.0).abs() < 1e-12));
|
assert!(f.items.iter().all(|i| (i.a - 1.0).abs() < 1e-12));
|
||||||
assert!(f.items.iter().all(|i| i.c.is_none()));
|
assert!(f.items.iter().all(|i| i.c.is_none()));
|
||||||
@@ -1111,14 +1109,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn three_pl_reports_a_lower_asymptote_and_warns() {
|
fn three_pl_reports_a_lower_asymptote_and_warns() {
|
||||||
let mut opts = Options::default();
|
let opts = Options {
|
||||||
opts.model = IrtModel::ThreePl;
|
model: IrtModel::ThreePl,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let f = fit(&ordered_matrix(), &opts);
|
let f = fit(&ordered_matrix(), &opts);
|
||||||
assert!(f.items.iter().all(|i| i.c.is_some()));
|
assert!(f.items.iter().all(|i| i.c.is_some()));
|
||||||
assert!(f
|
assert!(
|
||||||
.items
|
f.items
|
||||||
.iter()
|
.iter()
|
||||||
.all(|i| i.c.unwrap() >= 0.0 && i.c.unwrap() <= 0.4));
|
.all(|i| i.c.unwrap() >= 0.0 && i.c.unwrap() <= 0.4)
|
||||||
|
);
|
||||||
assert!(f.warnings.iter().any(|w| w.contains("1000")));
|
assert!(f.warnings.iter().any(|w| w.contains("1000")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -883,7 +883,7 @@ pub fn cluster(students: &[StudentSummary], k: usize) -> Vec<Archetype> {
|
|||||||
level_means,
|
level_means,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
out.sort_by(|a, b| b.members.len().cmp(&a.members.len()));
|
out.sort_by_key(|b| std::cmp::Reverse(b.members.len()));
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
//! generic derivation would emit as bare strings. The crate's own validation
|
//! generic derivation would emit as bare strings. The crate's own validation
|
||||||
//! remains authoritative; the schema is for the editor.
|
//! remains authoritative; the schema is for the editor.
|
||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::course::SCHEMA_VERSION;
|
use crate::course::SCHEMA_VERSION;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
@@ -998,9 +998,11 @@ mod tests {
|
|||||||
"# yaml-language-server: $schema=../.coursebank/schema/bank.schema.json"
|
"# yaml-language-server: $schema=../.coursebank/schema/bank.schema.json"
|
||||||
);
|
);
|
||||||
// A trailing slash must not double up.
|
// A trailing slash must not double up.
|
||||||
assert!(Kind::Course
|
assert!(
|
||||||
.modeline("schema/")
|
Kind::Course
|
||||||
.ends_with("schema/course.schema.json"));
|
.modeline("schema/")
|
||||||
|
.ends_with("schema/course.schema.json")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+13
-13
@@ -1058,11 +1058,7 @@ fn jaccard(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f64 {
|
|||||||
}
|
}
|
||||||
let inter = a.intersection(b).count() as f64;
|
let inter = a.intersection(b).count() as f64;
|
||||||
let union = a.union(b).count() as f64;
|
let union = a.union(b).count() as f64;
|
||||||
if union == 0.0 {
|
if union == 0.0 { 0.0 } else { inter / union }
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
inter / union
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Flesch-Kincaid grade level of a passage.
|
/// The Flesch-Kincaid grade level of a passage.
|
||||||
@@ -1313,8 +1309,9 @@ options:
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stem_without_a_task_is_flagged_but_completions_are_not() {
|
fn stem_without_a_task_is_flagged_but_completions_are_not() {
|
||||||
assert!(codes(
|
assert!(
|
||||||
r#"
|
codes(
|
||||||
|
r#"
|
||||||
id: q-a-001
|
id: q-a-001
|
||||||
status: draft
|
status: draft
|
||||||
level: 1
|
level: 1
|
||||||
@@ -1323,11 +1320,13 @@ options:
|
|||||||
- { id: A, text: aaaa, correct: true }
|
- { id: A, text: aaaa, correct: true }
|
||||||
- { id: B, text: bbbb }
|
- { id: B, text: bbbb }
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.contains(&"clarity-no-task"));
|
.contains(&"clarity-no-task")
|
||||||
|
);
|
||||||
|
|
||||||
assert!(!codes(
|
assert!(
|
||||||
r#"
|
!codes(
|
||||||
|
r#"
|
||||||
id: q-a-001
|
id: q-a-001
|
||||||
status: draft
|
status: draft
|
||||||
level: 1
|
level: 1
|
||||||
@@ -1336,8 +1335,9 @@ options:
|
|||||||
- { id: A, text: aaaa, correct: true }
|
- { id: A, text: aaaa, correct: true }
|
||||||
- { id: B, text: bbbb }
|
- { id: B, text: bbbb }
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.contains(&"clarity-no-task"));
|
.contains(&"clarity-no-task")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+10
-12
@@ -23,13 +23,12 @@
|
|||||||
|
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use crate::assessment::{
|
use crate::assessment::{Assessment, AssessmentFile, Blueprint, Form, Kind, Placement, Platform};
|
||||||
Assessment, AssessmentFile, Blueprint, Form, History, Kind, Placement, Platform,
|
|
||||||
};
|
|
||||||
use crate::catalog::Catalog;
|
use crate::catalog::Catalog;
|
||||||
use crate::course::SCHEMA_VERSION;
|
use crate::course::SCHEMA_VERSION;
|
||||||
use crate::date::Date;
|
use crate::date::Date;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
use crate::history::History;
|
||||||
use crate::rng::Rng;
|
use crate::rng::Rng;
|
||||||
use crate::taxonomy::Level;
|
use crate::taxonomy::Level;
|
||||||
|
|
||||||
@@ -444,14 +443,14 @@ pub fn to_record(
|
|||||||
) -> Result<AssessmentFile> {
|
) -> Result<AssessmentFile> {
|
||||||
let default_points = catalog.course.policy.points_per_item;
|
let default_points = catalog.course.policy.points_per_item;
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
let mut number = 1u32;
|
|
||||||
|
|
||||||
for (uid, is_bonus) in selection
|
for (number, (uid, is_bonus)) in (1u32..).zip(
|
||||||
.scored
|
selection
|
||||||
.iter()
|
.scored
|
||||||
.map(|u| (u, false))
|
.iter()
|
||||||
.chain(selection.bonus.iter().map(|u| (u, true)))
|
.map(|u| (u, false))
|
||||||
{
|
.chain(selection.bonus.iter().map(|u| (u, true))),
|
||||||
|
) {
|
||||||
let e = catalog.require(uid)?;
|
let e = catalog.require(uid)?;
|
||||||
items.push(Placement {
|
items.push(Placement {
|
||||||
number,
|
number,
|
||||||
@@ -466,7 +465,6 @@ pub fn to_record(
|
|||||||
credit_overrides: BTreeMap::new(),
|
credit_overrides: BTreeMap::new(),
|
||||||
dropped: false,
|
dropped: false,
|
||||||
});
|
});
|
||||||
number += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let form_list: Vec<Form> = (0..forms)
|
let form_list: Vec<Form> = (0..forms)
|
||||||
@@ -553,7 +551,7 @@ pub fn layout(record: &AssessmentFile, form: &Form) -> Vec<Placement> {
|
|||||||
rng.shuffle(&mut scored);
|
rng.shuffle(&mut scored);
|
||||||
}
|
}
|
||||||
|
|
||||||
scored.into_iter().chain(bonus.into_iter()).collect()
|
scored.into_iter().chain(bonus).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The option order for one item on one form.
|
/// The option order for one item on one form.
|
||||||
|
|||||||
@@ -8,10 +8,10 @@
|
|||||||
use coursebank::calibrate;
|
use coursebank::calibrate;
|
||||||
use coursebank::canvas;
|
use coursebank::canvas;
|
||||||
use coursebank::classical::{self, Thresholds};
|
use coursebank::classical::{self, Thresholds};
|
||||||
use coursebank::course::Layout;
|
|
||||||
use coursebank::error::Result;
|
use coursebank::error::Result;
|
||||||
use coursebank::gradescope;
|
use coursebank::gradescope;
|
||||||
use coursebank::irt;
|
use coursebank::irt;
|
||||||
|
use coursebank::layout::Layout;
|
||||||
use coursebank::report;
|
use coursebank::report;
|
||||||
use coursebank::store::{self, Store};
|
use coursebank::store::{self, Store};
|
||||||
use coursebank::students;
|
use coursebank::students;
|
||||||
@@ -114,8 +114,8 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
|
|||||||
}
|
}
|
||||||
println!("{}\n", analysis.reliability.interpretation());
|
println!("{}\n", analysis.reliability.interpretation());
|
||||||
println!(
|
println!(
|
||||||
"{:>3} {:>5} {:>6} {:>6} {:>6} {}",
|
"{:>3} {:>5} {:>6} {:>6} {:>6} FLAGS",
|
||||||
"Q", "p", "r", "D", "blank", "FLAGS"
|
"Q", "p", "r", "D", "blank"
|
||||||
);
|
);
|
||||||
for item in &analysis.items {
|
for item in &analysis.items {
|
||||||
println!(
|
println!(
|
||||||
@@ -184,8 +184,8 @@ pub(crate) fn analyze(cli: &Cli, sub: &AnalyzeCommand) -> Result<Outcome> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"{:>3} {:>6} {:>7} {:>7} {:>7} {}",
|
"{:>3} {:>6} {:>7} {:>7} {:>7} NOTES",
|
||||||
"Q", "a", "b", "SE(a)", "SE(b)", "NOTES"
|
"Q", "a", "b", "SE(a)", "SE(b)"
|
||||||
);
|
);
|
||||||
for item in &fit.items {
|
for item in &fit.items {
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -6,11 +6,12 @@
|
|||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use coursebank::assessment::{AssessmentFile, Blueprint, History};
|
use coursebank::assessment::{AssessmentFile, Blueprint};
|
||||||
use coursebank::bank::BankFile;
|
use coursebank::bank::BankFile;
|
||||||
use coursebank::course::Layout;
|
|
||||||
use coursebank::date::Date;
|
use coursebank::date::Date;
|
||||||
use coursebank::error::{Error, Result};
|
use coursebank::error::{Error, Result};
|
||||||
|
use coursebank::history::History;
|
||||||
|
use coursebank::layout::Layout;
|
||||||
use coursebank::select;
|
use coursebank::select;
|
||||||
use coursebank::yaml;
|
use coursebank::yaml;
|
||||||
|
|
||||||
@@ -102,7 +103,7 @@ pub(crate) fn assessment(cli: &Cli, sub: &AssessmentCommand) -> Result<Outcome>
|
|||||||
let catalog = load(cli)?;
|
let catalog = load(cli)?;
|
||||||
let record = load_record(&catalog, id)?;
|
let record = load_record(&catalog, id)?;
|
||||||
print_record(&catalog, &record);
|
print_record(&catalog, &record);
|
||||||
let issues = record.validate(Some(&catalog));
|
let issues = catalog.validate_record(&record);
|
||||||
if !issues.is_empty() {
|
if !issues.is_empty() {
|
||||||
println!("\n{} problem(s):", issues.len());
|
println!("\n{} problem(s):", issues.len());
|
||||||
for issue in &issues {
|
for issue in &issues {
|
||||||
@@ -213,7 +214,7 @@ pub(crate) fn usage(cli: &Cli, sub: &UsageCommand) -> Result<Outcome> {
|
|||||||
Some(id) => vec![catalog.resolve(id)?],
|
Some(id) => vec![catalog.resolve(id)?],
|
||||||
None => catalog.entries.iter().map(|e| e.uid.clone()).collect(),
|
None => catalog.entries.iter().map(|e| e.uid.clone()).collect(),
|
||||||
};
|
};
|
||||||
println!("{:<34} {:>5} {:<12} {}", "ITEM", "USES", "LAST", "WHERE");
|
println!("{:<34} {:>5} {:<12} WHERE", "ITEM", "USES", "LAST");
|
||||||
for uid in uids {
|
for uid in uids {
|
||||||
let uses = history.for_item(&uid);
|
let uses = history.for_item(&uid);
|
||||||
if uses.is_empty() && item.is_none() {
|
if uses.is_empty() && item.is_none() {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
//! [`pick_variants`], which resolves the `--variant` flags into a canonical list.
|
//! [`pick_variants`], which resolves the `--variant` flags into a canonical list.
|
||||||
|
|
||||||
use coursebank::assessment::Form;
|
use coursebank::assessment::Form;
|
||||||
use coursebank::course::Layout;
|
|
||||||
use coursebank::error::{Error, Result};
|
use coursebank::error::{Error, Result};
|
||||||
|
use coursebank::layout::Layout;
|
||||||
use coursebank::qti;
|
use coursebank::qti;
|
||||||
use coursebank::typst;
|
use coursebank::typst;
|
||||||
use coursebank::yaml;
|
use coursebank::yaml;
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ use std::collections::BTreeMap;
|
|||||||
|
|
||||||
use coursebank::assessment::AssessmentFile;
|
use coursebank::assessment::AssessmentFile;
|
||||||
use coursebank::bank::BankFile;
|
use coursebank::bank::BankFile;
|
||||||
use coursebank::course::{CourseFile, Layout, COURSE_FILE};
|
use coursebank::course::{COURSE_FILE, CourseFile};
|
||||||
use coursebank::error::{Error, Result};
|
use coursebank::error::{Error, Result};
|
||||||
use coursebank::jsonschema;
|
use coursebank::jsonschema;
|
||||||
|
use coursebank::layout::Layout;
|
||||||
use coursebank::lint::{self, Rule};
|
use coursebank::lint::{self, Rule};
|
||||||
use coursebank::taxonomy::Level;
|
use coursebank::taxonomy::Level;
|
||||||
use coursebank::yaml;
|
use coursebank::yaml;
|
||||||
@@ -98,7 +99,7 @@ pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
|
|||||||
let records = AssessmentFile::load_all(&catalog.layout.assessments())?;
|
let records = AssessmentFile::load_all(&catalog.layout.assessments())?;
|
||||||
let mut all = issues;
|
let mut all = issues;
|
||||||
for record in &records {
|
for record in &records {
|
||||||
for issue in record.validate(Some(&catalog)) {
|
for issue in catalog.validate_record(record) {
|
||||||
all.push(format!("{}: {issue}", record.assessment.id));
|
all.push(format!("{}: {issue}", record.assessment.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,7 @@ pub(crate) fn validate(cli: &Cli) -> Result<Outcome> {
|
|||||||
/// [`Outcome::Findings`] when anything fires, unless `--no-fail` was given.
|
/// [`Outcome::Findings`] when anything fires, unless `--no-fail` was given.
|
||||||
pub(crate) fn lint(cli: &Cli, args: &LintArgs) -> Result<Outcome> {
|
pub(crate) fn lint(cli: &Cli, args: &LintArgs) -> Result<Outcome> {
|
||||||
if args.list_rules {
|
if args.list_rules {
|
||||||
println!("{:<32} {:<8} {}", "CODE", "SEVERITY", "WHAT IT CATCHES");
|
println!("{:<32} {:<8} WHAT IT CATCHES", "CODE", "SEVERITY");
|
||||||
for rule in Rule::ALL {
|
for rule in Rule::ALL {
|
||||||
println!(
|
println!(
|
||||||
"{:<32} {:<8} {}",
|
"{:<32} {:<8} {}",
|
||||||
@@ -230,8 +231,8 @@ pub(crate) fn catalog(cli: &Cli, args: &CatalogArgs) -> Result<Outcome> {
|
|||||||
let coverage = catalog.coverage();
|
let coverage = catalog.coverage();
|
||||||
println!("\nObjective coverage:");
|
println!("\nObjective coverage:");
|
||||||
println!(
|
println!(
|
||||||
" {:<40} {:>6} {:>6} {}",
|
" {:<40} {:>6} {:>6} MAX LEVEL",
|
||||||
"OBJECTIVE", "ITEMS", "READY", "MAX LEVEL"
|
"OBJECTIVE", "ITEMS", "READY"
|
||||||
);
|
);
|
||||||
for row in &coverage.rows {
|
for row in &coverage.rows {
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
+9
-10
@@ -26,7 +26,7 @@ use crate::assessment::AssessmentFile;
|
|||||||
use crate::catalog::Catalog;
|
use crate::catalog::Catalog;
|
||||||
use crate::date::Date;
|
use crate::date::Date;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::responses::{administration_id, Response, ResponseSet};
|
use crate::responses::{Response, ResponseSet, administration_id};
|
||||||
|
|
||||||
/// What the ingest needs that the export does not carry.
|
/// What the ingest needs that the export does not carry.
|
||||||
pub type Context = crate::gradescope::Context;
|
pub type Context = crate::gradescope::Context;
|
||||||
@@ -120,11 +120,9 @@ pub fn normalize(s: &str) -> String {
|
|||||||
if c.is_alphanumeric() {
|
if c.is_alphanumeric() {
|
||||||
out.push(c);
|
out.push(c);
|
||||||
last_space = false;
|
last_space = false;
|
||||||
} else if c.is_whitespace() || c == '-' || c == '_' {
|
} else if (c.is_whitespace() || c == '-' || c == '_') && !last_space {
|
||||||
if !last_space {
|
out.push(' ');
|
||||||
out.push(' ');
|
last_space = true;
|
||||||
last_space = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Everything else — punctuation, entity leftovers — is dropped.
|
// Everything else — punctuation, entity leftovers — is dropped.
|
||||||
}
|
}
|
||||||
@@ -579,10 +577,11 @@ mod tests {
|
|||||||
|
|
||||||
// Two real students, two questions each. The preview row is discarded.
|
// Two real students, two questions each. The preview row is discarded.
|
||||||
assert_eq!(set.rows.len(), 4, "{:?}", set.warnings);
|
assert_eq!(set.rows.len(), 4, "{:?}", set.warnings);
|
||||||
assert!(set
|
assert!(
|
||||||
.rows
|
set.rows
|
||||||
.iter()
|
.iter()
|
||||||
.all(|r| r.name.as_deref() != Some("Test Student")));
|
.all(|r| r.name.as_deref() != Some("Test Student"))
|
||||||
|
);
|
||||||
assert_eq!(set.scored_total("1234567"), 2.0);
|
assert_eq!(set.scored_total("1234567"), 2.0);
|
||||||
assert_eq!(set.scored_total("7654321"), 1.0);
|
assert_eq!(set.scored_total("7654321"), 1.0);
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use crate::date::Date;
|
use crate::date::Date;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::responses::{administration_id, Response, ResponseSet};
|
use crate::responses::{Response, ResponseSet, administration_id};
|
||||||
use crate::taxonomy::Flag;
|
use crate::taxonomy::Flag;
|
||||||
|
|
||||||
/// What a rubric column represents.
|
/// What a rubric column represents.
|
||||||
@@ -358,7 +358,7 @@ pub fn parse_question(path: &Path) -> Result<Question> {
|
|||||||
"{} has no `Submission Time` column, so the rubric columns cannot be located; \
|
"{} has no `Submission Time` column, so the rubric columns cannot be located; \
|
||||||
is this a Gradescope per-question export?",
|
is this a Gradescope per-question export?",
|
||||||
path.display()
|
path.display()
|
||||||
)]))
|
)]));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let end = index_of("Adjustment").unwrap_or(header.len());
|
let end = index_of("Adjustment").unwrap_or(header.len());
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use arrow_array::{ArrayRef, BooleanArray, Float64Array, RecordBatch, StringArray, UInt32Array};
|
use arrow_array::{ArrayRef, BooleanArray, Float64Array, RecordBatch, StringArray, UInt32Array};
|
||||||
use arrow_schema::{DataType, Field, Schema};
|
use arrow_schema::{DataType, Field, Schema};
|
||||||
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
|
|
||||||
use parquet::arrow::ArrowWriter;
|
use parquet::arrow::ArrowWriter;
|
||||||
|
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
|
||||||
use parquet::basic::Compression;
|
use parquet::basic::Compression;
|
||||||
use parquet::file::properties::WriterProperties;
|
use parquet::file::properties::WriterProperties;
|
||||||
|
|
||||||
|
|||||||
@@ -145,11 +145,7 @@ pub fn student(
|
|||||||
out.push_str("## What this exam says about each learning objective\n\n");
|
out.push_str("## What this exam says about each learning objective\n\n");
|
||||||
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
|
out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n");
|
||||||
for o in &summary.objectives {
|
for o in &summary.objectives {
|
||||||
let you = if o.status == Mastery::NotEnoughEvidence {
|
let you = format!("{:.0}%", o.rate * 100.0);
|
||||||
format!("{:.0}%", o.rate * 100.0)
|
|
||||||
} else {
|
|
||||||
format!("{:.0}%", o.rate * 100.0)
|
|
||||||
};
|
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"| {} | {} | {} | {:.0}% | {} |\n",
|
"| {} | {} | {} | {:.0}% | {} |\n",
|
||||||
o.status.symbol(),
|
o.status.symbol(),
|
||||||
|
|||||||
+3
-3
@@ -53,16 +53,16 @@ pub mod value;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
pub use config::{
|
pub use config::{
|
||||||
ConfigFile, ContentMode, Fields, LetterStyle, Overrides, RenderConfig, Reveal, StimulusMode,
|
CONFIG_FILE, CONFIG_TEMPLATE, ConfigFile, ContentMode, Fields, LetterStyle, Overrides,
|
||||||
Variant, CONFIG_FILE, CONFIG_TEMPLATE,
|
RenderConfig, Reveal, StimulusMode, Variant,
|
||||||
};
|
};
|
||||||
pub use payload::Payload;
|
pub use payload::Payload;
|
||||||
pub use template::{Origin, Slot, Template};
|
pub use template::{Origin, Slot, Template};
|
||||||
pub use value::Value;
|
pub use value::Value;
|
||||||
|
|
||||||
|
use crate::Layout;
|
||||||
use crate::assessment::{AssessmentFile, Form};
|
use crate::assessment::{AssessmentFile, Form};
|
||||||
use crate::catalog::Catalog;
|
use crate::catalog::Catalog;
|
||||||
use crate::course::Layout;
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
/// What to render.
|
/// What to render.
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use crate::course::Layout;
|
use crate::Layout;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
use super::config::Variant;
|
use super::config::Variant;
|
||||||
@@ -702,9 +702,11 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn ordinary_comments_are_left_alone() {
|
fn ordinary_comments_are_left_alone() {
|
||||||
assert!(parse("// nothing to see\n// coursebank\n")
|
assert!(
|
||||||
.unwrap()
|
parse("// nothing to see\n// coursebank\n")
|
||||||
.is_inert());
|
.unwrap()
|
||||||
|
.is_inert()
|
||||||
|
);
|
||||||
// A word that merely starts with `begin` is a slot name, not a keyword.
|
// A word that merely starts with `begin` is a slot name, not a keyword.
|
||||||
let err = parse("// coursebank:beginning\n").unwrap_err();
|
let err = parse("// coursebank:beginning\n").unwrap_err();
|
||||||
assert!(err.to_string().contains("unknown slot `beginning`"));
|
assert!(err.to_string().contains("unknown slot `beginning`"));
|
||||||
|
|||||||
+3
-2
@@ -14,10 +14,11 @@ use std::path::Path;
|
|||||||
|
|
||||||
use coursebank::assessment::{AssessmentFile, Form};
|
use coursebank::assessment::{AssessmentFile, Form};
|
||||||
use coursebank::catalog::Catalog;
|
use coursebank::catalog::Catalog;
|
||||||
use coursebank::course::{Layout, COURSE_FILE};
|
use coursebank::course::COURSE_FILE;
|
||||||
use coursebank::date::Date;
|
use coursebank::date::Date;
|
||||||
use coursebank::error::{Error, Result};
|
use coursebank::error::{Error, Result};
|
||||||
use coursebank::gradescope;
|
use coursebank::gradescope;
|
||||||
|
use coursebank::layout::Layout;
|
||||||
use coursebank::responses::ResponseSet;
|
use coursebank::responses::ResponseSet;
|
||||||
use coursebank::store::Store;
|
use coursebank::store::Store;
|
||||||
use coursebank::taxonomy::Level;
|
use coursebank::taxonomy::Level;
|
||||||
@@ -223,7 +224,7 @@ pub(crate) fn print_record(catalog: &Catalog, record: &AssessmentFile) {
|
|||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
"estimated {:.0} minutes of working time",
|
"estimated {:.0} minutes of working time",
|
||||||
record.estimated_minutes(catalog)
|
catalog.estimated_minutes(record)
|
||||||
);
|
);
|
||||||
|
|
||||||
println!("\nBy level:");
|
println!("\nBy level:");
|
||||||
|
|||||||
+5
-3
@@ -44,7 +44,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Assessment records are the single source of truth for reuse history. There
|
//! Assessment records are the single source of truth for reuse history. There
|
||||||
//! is no separate ledger file, because a ledger duplicates what the records must
|
//! is no separate ledger file, because a ledger duplicates what the records must
|
||||||
//! already get right and then drifts from it. [`assessment::History`] derives usage
|
//! already get right and then drifts from it. [`history::History`] derives usage
|
||||||
//! by scanning the records.
|
//! by scanning the records.
|
||||||
//!
|
//!
|
||||||
//! Fingerprints cover only what a student saw. Retag an item's metadata and its
|
//! Fingerprints cover only what a student saw. Retag an item's metadata and its
|
||||||
@@ -96,7 +96,7 @@ pub mod util;
|
|||||||
|
|
||||||
pub use util::{date, hash, markup, rng, yaml, zipfile};
|
pub use util::{date, hash, markup, rng, yaml, zipfile};
|
||||||
|
|
||||||
pub use model::{assessment, bank, catalog, course, item, taxonomy};
|
pub use model::{assessment, bank, catalog, course, history, item, layout, taxonomy};
|
||||||
|
|
||||||
pub use authoring::{jsonschema, lint, select};
|
pub use authoring::{jsonschema, lint, select};
|
||||||
|
|
||||||
@@ -109,9 +109,11 @@ pub use analysis::{calibrate, classical, irt, students};
|
|||||||
pub use export::{qti, report, typst};
|
pub use export::{qti, report, typst};
|
||||||
|
|
||||||
pub use catalog::Catalog;
|
pub use catalog::Catalog;
|
||||||
pub use course::{CourseFile, Layout, SCHEMA_VERSION};
|
pub use course::{CourseFile, SCHEMA_VERSION};
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
|
pub use history::History;
|
||||||
pub use item::Item;
|
pub use item::Item;
|
||||||
|
pub use layout::Layout;
|
||||||
pub use taxonomy::{CognitiveProcess, ErrorType, Flag, Format, Level, Status};
|
pub use taxonomy::{CognitiveProcess, ErrorType, Flag, Format, Level, Status};
|
||||||
|
|
||||||
/// Version of package.
|
/// Version of package.
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@ use std::process::ExitCode;
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
use crate::cli::Cli;
|
use crate::cli::Cli;
|
||||||
use crate::commands::{run, Outcome};
|
use crate::commands::{Outcome, run};
|
||||||
|
|
||||||
/// Parses the command line, runs the requested command, and maps its result onto
|
/// Parses the command line, runs the requested command, and maps its result onto
|
||||||
/// a process exit code.
|
/// a process exit code.
|
||||||
|
|||||||
@@ -24,5 +24,7 @@ pub mod assessment;
|
|||||||
pub mod bank;
|
pub mod bank;
|
||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
pub mod course;
|
pub mod course;
|
||||||
|
pub mod history;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
|
pub mod layout;
|
||||||
pub mod taxonomy;
|
pub mod taxonomy;
|
||||||
|
|||||||
+5
-206
@@ -24,7 +24,6 @@ use std::path::Path;
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::catalog::Catalog;
|
|
||||||
use crate::course::SCHEMA_VERSION;
|
use crate::course::SCHEMA_VERSION;
|
||||||
use crate::date::Date;
|
use crate::date::Date;
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
@@ -396,7 +395,7 @@ impl AssessmentFile {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// Every problem found.
|
/// Every problem found.
|
||||||
pub fn validate(&self, catalog: Option<&Catalog>) -> Vec<String> {
|
pub fn validate(&self) -> Vec<String> {
|
||||||
let mut issues = Vec::new();
|
let mut issues = Vec::new();
|
||||||
|
|
||||||
if self.assessment.id.trim().is_empty() {
|
if self.assessment.id.trim().is_empty() {
|
||||||
@@ -458,60 +457,9 @@ impl AssessmentFile {
|
|||||||
form_ids.push(&form.id);
|
form_ids.push(&form.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(cat) = catalog {
|
|
||||||
for p in &self.items {
|
|
||||||
match cat.get(&p.item) {
|
|
||||||
None => {
|
|
||||||
issues.push(format!("question {}: unknown item `{}`", p.number, p.item))
|
|
||||||
}
|
|
||||||
Some(entry) => {
|
|
||||||
if let Some(fp) = &p.fingerprint {
|
|
||||||
if *fp != entry.item.fingerprint() {
|
|
||||||
issues.push(format!(
|
|
||||||
"question {} ({}): the item has been edited since this \
|
|
||||||
assessment; statistics from this administration describe \
|
|
||||||
the older wording",
|
|
||||||
p.number, p.item
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !p.key.is_empty() && p.key != entry.item.key_letters() {
|
|
||||||
issues.push(format!(
|
|
||||||
"question {} ({}): the recorded key {:?} differs from the \
|
|
||||||
item's current key {:?}",
|
|
||||||
p.number,
|
|
||||||
p.item,
|
|
||||||
p.key,
|
|
||||||
entry.item.key_letters()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
issues
|
issues
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Estimated time to complete, in minutes.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `catalog` - the loaded course, for per-item time estimates.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// The estimate.
|
|
||||||
pub fn estimated_minutes(&self, catalog: &Catalog) -> f64 {
|
|
||||||
let seconds: f64 = self
|
|
||||||
.items
|
|
||||||
.iter()
|
|
||||||
.filter_map(|p| catalog.get(&p.item))
|
|
||||||
.map(|e| e.item.expected_seconds())
|
|
||||||
.sum();
|
|
||||||
seconds / 60.0
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A skeleton record for `coursebank assessment new`.
|
/// A skeleton record for `coursebank assessment new`.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
@@ -566,113 +514,6 @@ pub struct Usage {
|
|||||||
pub fingerprint: Option<String>,
|
pub fingerprint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The usage history of a course, built by scanning assessment records.
|
|
||||||
///
|
|
||||||
/// There is deliberately no separate ledger file. A ledger duplicates
|
|
||||||
/// information that the records already hold and then drifts away from it; this
|
|
||||||
/// derives the same answers from the one artifact that has to be right anyway.
|
|
||||||
#[derive(Debug, Clone, Default)]
|
|
||||||
pub struct History {
|
|
||||||
/// Every recorded appearance, newest last.
|
|
||||||
pub usages: Vec<Usage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl History {
|
|
||||||
/// Builds the history from a directory of assessment records.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `dir` - the assessments directory.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// The history.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Propagates load errors.
|
|
||||||
pub fn load(dir: &Path) -> Result<History> {
|
|
||||||
let mut usages = Vec::new();
|
|
||||||
for file in AssessmentFile::load_all(dir)? {
|
|
||||||
for p in &file.items {
|
|
||||||
usages.push(Usage {
|
|
||||||
item: p.item.clone(),
|
|
||||||
assessment: file.assessment.id.clone(),
|
|
||||||
title: file.assessment.title.clone(),
|
|
||||||
kind: file.assessment.kind,
|
|
||||||
date: file.assessment.date,
|
|
||||||
number: p.number,
|
|
||||||
fingerprint: p.fingerprint.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usages.sort_by(|a, b| {
|
|
||||||
a.date
|
|
||||||
.cmp(&b.date)
|
|
||||||
.then(a.assessment.cmp(&b.assessment))
|
|
||||||
.then(a.number.cmp(&b.number))
|
|
||||||
});
|
|
||||||
Ok(History { usages })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every appearance of one item, oldest first.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `uid` - the item's global id.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// Matching usages.
|
|
||||||
pub fn for_item(&self, uid: &str) -> Vec<&Usage> {
|
|
||||||
self.usages.iter().filter(|u| u.item == uid).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The most recent date an item was used.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `uid` - the item's global id.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// The date, or `None` when never used or never dated.
|
|
||||||
pub fn last_used(&self, uid: &str) -> Option<Date> {
|
|
||||||
self.for_item(uid).iter().filter_map(|u| u.date).max()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// How many times an item has been used.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `uid` - the item's global id.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// The count.
|
|
||||||
pub fn use_count(&self, uid: &str) -> usize {
|
|
||||||
self.for_item(uid).len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether an item is still inside its reuse cooldown.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `uid` - the item's global id.
|
|
||||||
/// * `cooldown_days` - the minimum gap between uses.
|
|
||||||
/// * `as_of` - the date of the assessment being assembled.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// `true` when the item was used too recently to reuse.
|
|
||||||
pub fn in_cooldown(&self, uid: &str, cooldown_days: i64, as_of: Date) -> bool {
|
|
||||||
match self.last_used(uid) {
|
|
||||||
Some(last) => last.days_until(as_of) < cooldown_days,
|
|
||||||
None => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_version() -> String {
|
fn default_version() -> String {
|
||||||
SCHEMA_VERSION.to_string()
|
SCHEMA_VERSION.to_string()
|
||||||
}
|
}
|
||||||
@@ -716,7 +557,7 @@ items:
|
|||||||
assert_eq!(a.items.len(), 3);
|
assert_eq!(a.items.len(), 3);
|
||||||
assert_eq!(a.total_points(1.0), 3.0, "bonus is excluded");
|
assert_eq!(a.total_points(1.0), 3.0, "bonus is excluded");
|
||||||
assert_eq!(a.placement(2).unwrap().item, "b1::q-a-002");
|
assert_eq!(a.placement(2).unwrap().item, "b1::q-a-002");
|
||||||
assert!(a.validate(None).is_empty(), "{:?}", a.validate(None));
|
assert!(a.validate().is_empty(), "{:?}", a.validate());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -737,7 +578,7 @@ items:
|
|||||||
- { number: 1, item: "b::q-1" }
|
- { number: 1, item: "b::q-1" }
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let issues = a.validate(None);
|
let issues = a.validate();
|
||||||
assert!(issues.iter().any(|i| i.contains("used 2 times")));
|
assert!(issues.iter().any(|i| i.contains("used 2 times")));
|
||||||
assert!(issues.iter().any(|i| i.contains("appears twice")));
|
assert!(issues.iter().any(|i| i.contains("appears twice")));
|
||||||
}
|
}
|
||||||
@@ -752,10 +593,7 @@ items:
|
|||||||
- { number: 3, item: "b::q-2" }
|
- { number: 3, item: "b::q-2" }
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(a
|
assert!(a.validate().iter().any(|i| i.contains("not contiguous")));
|
||||||
.validate(None)
|
|
||||||
.iter()
|
|
||||||
.any(|i| i.contains("not contiguous")));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -767,46 +605,7 @@ items:
|
|||||||
- { number: 1, item: "b::q-1", credit_overrides: { B: 1.5 } }
|
- { number: 1, item: "b::q-1", credit_overrides: { B: 1.5 } }
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
assert!(a
|
assert!(a.validate().iter().any(|i| i.contains("must be in [0, 1]")));
|
||||||
.validate(None)
|
|
||||||
.iter()
|
|
||||||
.any(|i| i.contains("must be in [0, 1]")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn history_tracks_last_use_and_cooldown() {
|
|
||||||
let h = History {
|
|
||||||
usages: vec![
|
|
||||||
Usage {
|
|
||||||
item: "b::q-1".into(),
|
|
||||||
assessment: "exam-1".into(),
|
|
||||||
title: "Exam 1".into(),
|
|
||||||
kind: Kind::Exam,
|
|
||||||
date: Some("2025-09-15".parse().unwrap()),
|
|
||||||
number: 4,
|
|
||||||
fingerprint: None,
|
|
||||||
},
|
|
||||||
Usage {
|
|
||||||
item: "b::q-1".into(),
|
|
||||||
assessment: "exam-3".into(),
|
|
||||||
title: "Exam 3".into(),
|
|
||||||
kind: Kind::Exam,
|
|
||||||
date: Some("2026-02-10".parse().unwrap()),
|
|
||||||
number: 7,
|
|
||||||
fingerprint: None,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
assert_eq!(h.use_count("b::q-1"), 2);
|
|
||||||
assert_eq!(h.use_count("b::q-2"), 0);
|
|
||||||
assert_eq!(h.last_used("b::q-1").unwrap().to_string(), "2026-02-10");
|
|
||||||
|
|
||||||
let exam_date: Date = "2026-04-23".parse().unwrap();
|
|
||||||
// 72 days elapsed, so a 90-day cooldown still blocks it and a 60-day one
|
|
||||||
// does not.
|
|
||||||
assert!(h.in_cooldown("b::q-1", 90, exam_date));
|
|
||||||
assert!(!h.in_cooldown("b::q-1", 60, exam_date));
|
|
||||||
assert!(!h.in_cooldown("b::never-used", 3650, exam_date));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+11
-7
@@ -491,7 +491,7 @@ fn validate_item(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (letter, _) in &c.option_stats {
|
for letter in c.option_stats.keys() {
|
||||||
if it.option(letter).is_none() {
|
if it.option(letter).is_none() {
|
||||||
issues.push(format!(
|
issues.push(format!(
|
||||||
"calibration.option_stats has `{letter}`, which is not an option of this item"
|
"calibration.option_stats has `{letter}`, which is not an option of this item"
|
||||||
@@ -662,9 +662,11 @@ mod tests {
|
|||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let issues = b.validate(None);
|
let issues = b.validate(None);
|
||||||
assert!(issues
|
assert!(
|
||||||
.iter()
|
issues
|
||||||
.any(|i| i.contains("exactly one keyed option")));
|
.iter()
|
||||||
|
.any(|i| i.contains("exactly one keyed option"))
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
issues
|
issues
|
||||||
.iter()
|
.iter()
|
||||||
@@ -831,9 +833,11 @@ learning_objectives:
|
|||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let issues = b.validate(Some(&course));
|
let issues = b.validate(Some(&course));
|
||||||
assert!(issues
|
assert!(
|
||||||
.iter()
|
issues
|
||||||
.any(|i| i.contains("unknown learning objective `lo-unknown`")));
|
.iter()
|
||||||
|
.any(|i| i.contains("unknown learning objective `lo-unknown`"))
|
||||||
|
);
|
||||||
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
|
assert!(issues.iter().any(|i| i.contains("unknown lecture `L99`")));
|
||||||
assert!(
|
assert!(
|
||||||
issues.iter().any(|i| i.contains("exceeds the ceiling")),
|
issues.iter().any(|i| i.contains("exceeds the ceiling")),
|
||||||
|
|||||||
+44
-2
@@ -16,10 +16,12 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::assessment::AssessmentFile;
|
||||||
use crate::bank::BankFile;
|
use crate::bank::BankFile;
|
||||||
use crate::course::{CourseFile, Layout};
|
use crate::course::CourseFile;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::item::Item;
|
use crate::item::Item;
|
||||||
|
use crate::layout::Layout;
|
||||||
use crate::taxonomy::{Level, Status};
|
use crate::taxonomy::{Level, Status};
|
||||||
use crate::yaml;
|
use crate::yaml;
|
||||||
|
|
||||||
@@ -249,6 +251,46 @@ impl Catalog {
|
|||||||
Ok(issues)
|
Ok(issues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates a record's internal invariants, then its references against this
|
||||||
|
/// catalog: unknown items, keys that drifted, and fingerprints showing the
|
||||||
|
/// item was reworded since it was administered.
|
||||||
|
pub fn validate_record(&self, record: &AssessmentFile) -> Vec<String> {
|
||||||
|
let mut issues = record.validate();
|
||||||
|
for p in &record.items {
|
||||||
|
match self.get(&p.item) {
|
||||||
|
None => issues.push(format!("question {}: unknown item `{}`", p.number, p.item)),
|
||||||
|
Some(entry) => {
|
||||||
|
if let Some(fp) = &p.fingerprint {
|
||||||
|
if *fp != entry.item.fingerprint() {
|
||||||
|
issues.push(format!(
|
||||||
|
"question {} ({}): the item has been edited since this assessment; \
|
||||||
|
statistics from this administration describe the older wording",
|
||||||
|
p.number, p.item
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !p.key.is_empty() && p.key != entry.item.key_letters() {
|
||||||
|
issues.push(format!(
|
||||||
|
"question {} ({}): the recorded key {:?} differs from the item's current key {:?}",
|
||||||
|
p.number, p.item, p.key, entry.item.key_letters()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
issues
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Estimated working time for a record, in minutes.
|
||||||
|
pub fn estimated_minutes(&self, record: &AssessmentFile) -> f64 {
|
||||||
|
let seconds: f64 = record
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|p| self.get(&p.item))
|
||||||
|
.map(|e| e.item.expected_seconds())
|
||||||
|
.sum();
|
||||||
|
seconds / 60.0
|
||||||
|
}
|
||||||
|
|
||||||
/// Counts of assemblable, non-bonus items by level.
|
/// Counts of assemblable, non-bonus items by level.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
@@ -391,7 +433,7 @@ impl Catalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (lec_id, _) in &self.course.lectures {
|
for lec_id in self.course.lectures.keys() {
|
||||||
if self
|
if self
|
||||||
.by_lecture(lec_id)
|
.by_lecture(lec_id)
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+3
-94
@@ -12,7 +12,7 @@
|
|||||||
//! belongs to the course and the administration, never to the item.
|
//! belongs to the course and the administration, never to the item.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -307,7 +307,7 @@ impl CourseFile {
|
|||||||
issues.push(format!("lecture `{id}`: empty title"));
|
issues.push(format!("lecture `{id}`: empty title"));
|
||||||
}
|
}
|
||||||
if let Some(u) = &lec.unit {
|
if let Some(u) = &lec.unit {
|
||||||
if !unit_ids.iter().any(|x| *x == u) {
|
if !unit_ids.contains(&u) {
|
||||||
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
|
issues.push(format!("lecture `{id}`: unknown unit `{u}`"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,7 +318,7 @@ impl CourseFile {
|
|||||||
issues.push(format!("objective `{id}`: empty text"));
|
issues.push(format!("objective `{id}`: empty text"));
|
||||||
}
|
}
|
||||||
if let Some(u) = &lo.unit {
|
if let Some(u) = &lo.unit {
|
||||||
if !unit_ids.iter().any(|x| *x == u) {
|
if !unit_ids.contains(&u) {
|
||||||
issues.push(format!("objective `{id}`: unknown unit `{u}`"));
|
issues.push(format!("objective `{id}`: unknown unit `{u}`"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -550,97 +550,6 @@ impl CourseFile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The standard directory layout of a course, resolved from a root.
|
|
||||||
///
|
|
||||||
/// Keeping the layout in one type means every command agrees on where things
|
|
||||||
/// live, and a future rearrangement touches this struct only.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Layout {
|
|
||||||
/// The course root.
|
|
||||||
pub root: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Layout {
|
|
||||||
/// Builds a layout from a course root directory.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `root` - the course directory.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// The layout.
|
|
||||||
pub fn new(root: impl Into<PathBuf>) -> Layout {
|
|
||||||
Layout { root: root.into() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Path to `course.yaml`.
|
|
||||||
pub fn course_file(&self) -> PathBuf {
|
|
||||||
self.root.join(COURSE_FILE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Directory holding item bank YAML files.
|
|
||||||
pub fn banks(&self) -> PathBuf {
|
|
||||||
self.root.join("banks")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Directory holding assessment records.
|
|
||||||
pub fn assessments(&self) -> PathBuf {
|
|
||||||
self.root.join("assessments")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Directory holding response tables and derived statistics.
|
|
||||||
pub fn data(&self) -> PathBuf {
|
|
||||||
self.root.join("data")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Directory holding generated reports.
|
|
||||||
pub fn reports(&self) -> PathBuf {
|
|
||||||
self.root.join("reports")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Directory holding generated exports such as QTI packages.
|
|
||||||
pub fn build(&self) -> PathBuf {
|
|
||||||
self.root.join("build")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Directory holding emitted JSON Schema files for editor validation.
|
|
||||||
pub fn schema(&self) -> PathBuf {
|
|
||||||
self.root.join("schema")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Directory holding Typst export templates and their configuration.
|
|
||||||
///
|
|
||||||
/// Unlike the other directories, this one is *not* created by
|
|
||||||
/// [`Layout::create_all`]. Its absence is meaningful: a course with no
|
|
||||||
/// `templates/` directory uses the templates compiled into the binary, and
|
|
||||||
/// creating an empty one on `init` would suggest a customization step is
|
|
||||||
/// required when it is not. `coursebank template dump` creates it on demand.
|
|
||||||
pub fn templates(&self) -> PathBuf {
|
|
||||||
self.root.join("templates")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates every directory in the layout.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
///
|
|
||||||
/// Returns [`Error::Io`] if a directory cannot be created.
|
|
||||||
pub fn create_all(&self) -> Result<()> {
|
|
||||||
for dir in [
|
|
||||||
self.root.clone(),
|
|
||||||
self.banks(),
|
|
||||||
self.assessments(),
|
|
||||||
self.data(),
|
|
||||||
self.reports(),
|
|
||||||
self.build(),
|
|
||||||
self.schema(),
|
|
||||||
] {
|
|
||||||
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lowercases and hyphenates a string for use in file names and ids.
|
/// Lowercases and hyphenates a string for use in file names and ids.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
//! The usage history of a course, derived by scanning assessment records.
|
||||||
|
//!
|
||||||
|
//! There is deliberately no separate ledger file. A ledger duplicates
|
||||||
|
//! information the records already hold and then drifts away from it; this
|
||||||
|
//! derives the same answers from the one artifact that has to be right anyway.
|
||||||
|
//! An item was used exactly when it appears on a record.
|
||||||
|
//!
|
||||||
|
//! This is an aggregate over the records, the same kind of thing as
|
||||||
|
//! [`crate::catalog::Catalog`], which is why it sits beside the schema rather
|
||||||
|
//! than inside [`crate::assessment`].
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::assessment::{AssessmentFile, Kind};
|
||||||
|
use crate::date::Date;
|
||||||
|
use crate::error::Result;
|
||||||
|
|
||||||
|
/// One recorded appearance of an item, derived from the assessment records.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Usage {
|
||||||
|
/// The item's global id.
|
||||||
|
pub item: String,
|
||||||
|
/// The assessment id it appeared on.
|
||||||
|
pub assessment: String,
|
||||||
|
/// The assessment title.
|
||||||
|
pub title: String,
|
||||||
|
/// The kind of assessment.
|
||||||
|
pub kind: Kind,
|
||||||
|
/// When it was administered, when recorded.
|
||||||
|
pub date: Option<Date>,
|
||||||
|
/// The printed question number.
|
||||||
|
pub number: u32,
|
||||||
|
/// The fingerprint as used.
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The usage history of a course, built by scanning assessment records.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct History {
|
||||||
|
/// Every recorded appearance, newest last.
|
||||||
|
pub usages: Vec<Usage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl History {
|
||||||
|
/// Builds the history from a directory of assessment records.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `dir` - the assessments directory.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The history.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Propagates load errors.
|
||||||
|
pub fn load(dir: &Path) -> Result<History> {
|
||||||
|
let mut usages = Vec::new();
|
||||||
|
for file in AssessmentFile::load_all(dir)? {
|
||||||
|
for p in &file.items {
|
||||||
|
usages.push(Usage {
|
||||||
|
item: p.item.clone(),
|
||||||
|
assessment: file.assessment.id.clone(),
|
||||||
|
title: file.assessment.title.clone(),
|
||||||
|
kind: file.assessment.kind,
|
||||||
|
date: file.assessment.date,
|
||||||
|
number: p.number,
|
||||||
|
fingerprint: p.fingerprint.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usages.sort_by(|a, b| {
|
||||||
|
a.date
|
||||||
|
.cmp(&b.date)
|
||||||
|
.then(a.assessment.cmp(&b.assessment))
|
||||||
|
.then(a.number.cmp(&b.number))
|
||||||
|
});
|
||||||
|
Ok(History { usages })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every appearance of one item, oldest first.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `uid` - the item's global id.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Matching usages.
|
||||||
|
pub fn for_item(&self, uid: &str) -> Vec<&Usage> {
|
||||||
|
self.usages.iter().filter(|u| u.item == uid).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most recent date an item was used.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `uid` - the item's global id.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The date, or `None` when never used or never dated.
|
||||||
|
pub fn last_used(&self, uid: &str) -> Option<Date> {
|
||||||
|
self.for_item(uid).iter().filter_map(|u| u.date).max()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many times an item has been used.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `uid` - the item's global id.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The count.
|
||||||
|
pub fn use_count(&self, uid: &str) -> usize {
|
||||||
|
self.for_item(uid).len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an item is still inside its reuse cooldown.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `uid` - the item's global id.
|
||||||
|
/// * `cooldown_days` - the minimum gap between uses.
|
||||||
|
/// * `as_of` - the date of the assessment being assembled.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// `true` when the item was used too recently to reuse.
|
||||||
|
pub fn in_cooldown(&self, uid: &str, cooldown_days: i64, as_of: Date) -> bool {
|
||||||
|
match self.last_used(uid) {
|
||||||
|
Some(last) => last.days_until(as_of) < cooldown_days,
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_tracks_last_use_and_cooldown() {
|
||||||
|
let h = History {
|
||||||
|
usages: vec![
|
||||||
|
Usage {
|
||||||
|
item: "b::q-1".into(),
|
||||||
|
assessment: "exam-1".into(),
|
||||||
|
title: "Exam 1".into(),
|
||||||
|
kind: Kind::Exam,
|
||||||
|
date: Some("2025-09-15".parse().unwrap()),
|
||||||
|
number: 4,
|
||||||
|
fingerprint: None,
|
||||||
|
},
|
||||||
|
Usage {
|
||||||
|
item: "b::q-1".into(),
|
||||||
|
assessment: "exam-3".into(),
|
||||||
|
title: "Exam 3".into(),
|
||||||
|
kind: Kind::Exam,
|
||||||
|
date: Some("2026-02-10".parse().unwrap()),
|
||||||
|
number: 7,
|
||||||
|
fingerprint: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert_eq!(h.use_count("b::q-1"), 2);
|
||||||
|
assert_eq!(h.use_count("b::q-2"), 0);
|
||||||
|
assert_eq!(h.last_used("b::q-1").unwrap().to_string(), "2026-02-10");
|
||||||
|
|
||||||
|
let exam_date: Date = "2026-04-23".parse().unwrap();
|
||||||
|
// 72 days elapsed, so a 90-day cooldown still blocks it and a 60-day one
|
||||||
|
// does not.
|
||||||
|
assert!(h.in_cooldown("b::q-1", 90, exam_date));
|
||||||
|
assert!(!h.in_cooldown("b::q-1", 60, exam_date));
|
||||||
|
assert!(!h.in_cooldown("b::never-used", 3650, exam_date));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//! The on-disk layout of a course directory.
|
||||||
|
//!
|
||||||
|
//! Keeping the layout in one type means every command agrees on where things
|
||||||
|
//! live, and a future rearrangement touches this file only. It is deliberately
|
||||||
|
//! separate from the course *schema* in [`crate::course`]: this module is about
|
||||||
|
//! where files sit on disk, not what they contain.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use crate::course::COURSE_FILE;
|
||||||
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
|
/// The standard directory layout of a course, resolved from a root.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Layout {
|
||||||
|
/// The course root.
|
||||||
|
pub root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
/// Builds a layout from a course root directory.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `root` - the course directory.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The layout.
|
||||||
|
pub fn new(root: impl Into<PathBuf>) -> Layout {
|
||||||
|
Layout { root: root.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to `course.yaml`.
|
||||||
|
pub fn course_file(&self) -> PathBuf {
|
||||||
|
self.root.join(COURSE_FILE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory holding item bank YAML files.
|
||||||
|
pub fn banks(&self) -> PathBuf {
|
||||||
|
self.root.join("banks")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory holding assessment records.
|
||||||
|
pub fn assessments(&self) -> PathBuf {
|
||||||
|
self.root.join("assessments")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory holding response tables and derived statistics.
|
||||||
|
pub fn data(&self) -> PathBuf {
|
||||||
|
self.root.join("data")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory holding generated reports.
|
||||||
|
pub fn reports(&self) -> PathBuf {
|
||||||
|
self.root.join("reports")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory holding generated exports such as QTI packages.
|
||||||
|
pub fn build(&self) -> PathBuf {
|
||||||
|
self.root.join("build")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory holding emitted JSON Schema files for editor validation.
|
||||||
|
pub fn schema(&self) -> PathBuf {
|
||||||
|
self.root.join("schema")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory holding Typst export templates and their configuration.
|
||||||
|
///
|
||||||
|
/// Unlike the other directories, this one is *not* created by
|
||||||
|
/// [`Layout::create_all`]. Its absence is meaningful: a course with no
|
||||||
|
/// `templates/` directory uses the templates compiled into the binary, and
|
||||||
|
/// creating an empty one on `init` would suggest a customization step is
|
||||||
|
/// required when it is not. `coursebank template dump` creates it on demand.
|
||||||
|
pub fn templates(&self) -> PathBuf {
|
||||||
|
self.root.join("templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates every directory in the layout.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`Error::Io`] if a directory cannot be created.
|
||||||
|
pub fn create_all(&self) -> Result<()> {
|
||||||
|
for dir in [
|
||||||
|
self.root.clone(),
|
||||||
|
self.banks(),
|
||||||
|
self.assessments(),
|
||||||
|
self.data(),
|
||||||
|
self.reports(),
|
||||||
|
self.build(),
|
||||||
|
self.schema(),
|
||||||
|
] {
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -137,9 +137,9 @@ pub fn sha256(msg: &[u8]) -> [u8; 32] {
|
|||||||
|
|
||||||
let mut w = [0u32; 64];
|
let mut w = [0u32; 64];
|
||||||
for chunk in data.chunks(64) {
|
for chunk in data.chunks(64) {
|
||||||
for i in 0..16 {
|
for (i, w_i) in w.iter_mut().enumerate().take(16) {
|
||||||
let j = i * 4;
|
let j = i * 4;
|
||||||
w[i] = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
|
*w_i = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
|
||||||
}
|
}
|
||||||
for i in 16..64 {
|
for i in 16..64 {
|
||||||
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||||
|
|||||||
+1
-1
@@ -292,7 +292,7 @@ fn wrap_delimited(s: &str, delim: &str, pre: &str, post: &str) -> String {
|
|||||||
out.push_str(rest);
|
out.push_str(rest);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
Some(j) if j == 0 => {
|
Some(0) => {
|
||||||
// Empty span such as `**`; emit literally and move on.
|
// Empty span such as `**`; emit literally and move on.
|
||||||
out.push_str(&rest[..i + delim.len()]);
|
out.push_str(&rest[..i + delim.len()]);
|
||||||
rest = after;
|
rest = after;
|
||||||
|
|||||||
Reference in New Issue
Block a user