feat: initial package draft
This commit is contained in:
@@ -0,0 +1,619 @@
|
||||
//! Reading Canvas's "Student Analysis" quiz export.
|
||||
//!
|
||||
//! Canvas exports one wide CSV for the whole quiz. After the student columns come
|
||||
//! two columns per question: one headed `<question id>: <question text>` holding
|
||||
//! the answer text the student chose, and one immediately after it holding the
|
||||
//! points earned, headed with the points possible.
|
||||
//!
|
||||
//! The awkward part is that Canvas records answer text, not option letters. So
|
||||
//! recovering "this student chose C" means matching the exported text back against
|
||||
//! the item's options. That matching is done on normalized text because the text
|
||||
//! makes a round trip through the QTI export and Canvas's own HTML sanitizer, and
|
||||
//! comes back with different markup than it left with.
|
||||
//!
|
||||
//! When a match cannot be made, the row keeps its score and loses its option
|
||||
//! letter, with a warning naming the question. Totals and per-objective mastery
|
||||
//! still work, and only distractor analysis is affected.
|
||||
//!
|
||||
//! Questions are aligned to item numbers by matching stem text the same way, with
|
||||
//! column order as the fallback. Order alone would be wrong for any quiz where
|
||||
//! Canvas shuffled the questions.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::assessment::AssessmentFile;
|
||||
use crate::catalog::Catalog;
|
||||
use crate::date::Date;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::responses::{administration_id, Response, ResponseSet};
|
||||
|
||||
/// What the ingest needs that the export does not carry.
|
||||
pub type Context = crate::gradescope::Context;
|
||||
|
||||
/// One question column pair found in the header.
|
||||
#[derive(Debug, Clone)]
|
||||
struct QuestionColumn {
|
||||
/// Index of the answer-text column.
|
||||
text_index: usize,
|
||||
/// Index of the points-earned column, when the header had one.
|
||||
points_index: Option<usize>,
|
||||
/// The Canvas question id, from the column header.
|
||||
///
|
||||
/// Kept for diagnostics rather than for matching: when a column cannot be
|
||||
/// traced to the assessment record, or an answer cannot be traced to an option,
|
||||
/// this id is what lets you find the question in Canvas and see what differs.
|
||||
/// Matching itself goes by question text, because Canvas ids change when a quiz
|
||||
/// is copied to a new term.
|
||||
canvas_id: String,
|
||||
/// The question text from the header.
|
||||
question_text: String,
|
||||
/// Points possible, parsed from the points column's header.
|
||||
points_possible: f64,
|
||||
/// The item number this maps to, once resolved.
|
||||
item_number: Option<u32>,
|
||||
}
|
||||
|
||||
/// Splits a question column header into its id and text.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `header` - the column header.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The id and text, or `None` when the header is not a question column.
|
||||
fn split_question_header(header: &str) -> Option<(String, String)> {
|
||||
let (id, rest) = header.split_once(':')?;
|
||||
let id = id.trim();
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
Some((id.to_string(), rest.trim().to_string()))
|
||||
}
|
||||
|
||||
/// Normalizes text for comparison.
|
||||
///
|
||||
/// Strips HTML tags, decodes the handful of entities that survive a QTI round
|
||||
/// trip, drops punctuation, lowercases, and collapses whitespace.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` - the text.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The normalized form.
|
||||
pub fn normalize(s: &str) -> String {
|
||||
// Strip tags.
|
||||
let mut stripped = String::with_capacity(s.len());
|
||||
let mut in_tag = false;
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'<' => in_tag = true,
|
||||
'>' => {
|
||||
in_tag = false;
|
||||
stripped.push(' ');
|
||||
}
|
||||
_ if in_tag => {}
|
||||
_ => stripped.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
// Decode the entities that actually appear.
|
||||
let decoded = stripped
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("→", "->")
|
||||
.replace("↔", "<->")
|
||||
.replace("−", "-");
|
||||
|
||||
let mut out = String::with_capacity(decoded.len());
|
||||
let mut last_space = true;
|
||||
for ch in decoded.chars() {
|
||||
let c = ch.to_ascii_lowercase();
|
||||
if c.is_alphanumeric() {
|
||||
out.push(c);
|
||||
last_space = false;
|
||||
} else if c.is_whitespace() || c == '-' || c == '_' {
|
||||
if !last_space {
|
||||
out.push(' ');
|
||||
last_space = true;
|
||||
}
|
||||
}
|
||||
// Everything else — punctuation, entity leftovers — is dropped.
|
||||
}
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
/// Reads a Canvas Student Analysis export.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `path` - the CSV file.
|
||||
/// * `ctx` - identifying information.
|
||||
/// * `record` - the assessment record, used to align questions to item numbers.
|
||||
/// * `catalog` - the loaded course, used to recover option letters from answer
|
||||
/// text. Without it, scores are still ingested but letters are not.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The normalized responses.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`Error::Csv`] on a malformed file and [`Error::Invalid`] when no
|
||||
/// question columns can be found.
|
||||
pub fn ingest(
|
||||
path: &Path,
|
||||
ctx: &Context,
|
||||
record: Option<&AssessmentFile>,
|
||||
catalog: Option<&Catalog>,
|
||||
) -> Result<ResponseSet> {
|
||||
let mut reader = csv::ReaderBuilder::new()
|
||||
.flexible(true)
|
||||
.has_headers(true)
|
||||
.from_path(path)
|
||||
.map_err(|e| Error::Csv {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let header = reader
|
||||
.headers()
|
||||
.map_err(|e| Error::Csv {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?
|
||||
.clone();
|
||||
let header: Vec<String> = header.iter().map(|h| h.trim().to_string()).collect();
|
||||
|
||||
let find = |name: &str| header.iter().position(|h| h.eq_ignore_ascii_case(name));
|
||||
let name_col = find("name");
|
||||
let id_col = find("id");
|
||||
let sis_col = find("sis_id").or_else(|| find("sis id"));
|
||||
let section_col = find("section");
|
||||
let submitted_col = find("submitted");
|
||||
let attempt_col = find("attempt");
|
||||
|
||||
// Locate the question column pairs.
|
||||
let mut questions: Vec<QuestionColumn> = Vec::new();
|
||||
for (i, h) in header.iter().enumerate() {
|
||||
if let Some((canvas_id, question_text)) = split_question_header(h) {
|
||||
// The next column holds points earned; its header is the points
|
||||
// possible. Canvas writes it as a bare number.
|
||||
let (points_index, points_possible) = match header.get(i + 1) {
|
||||
Some(next) => match next.trim().parse::<f64>() {
|
||||
Ok(p) => (Some(i + 1), p),
|
||||
Err(_) => (None, 0.0),
|
||||
},
|
||||
None => (None, 0.0),
|
||||
};
|
||||
questions.push(QuestionColumn {
|
||||
text_index: i,
|
||||
points_index,
|
||||
canvas_id,
|
||||
question_text,
|
||||
points_possible,
|
||||
item_number: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if questions.is_empty() {
|
||||
return Err(Error::Invalid(vec![format!(
|
||||
"{} has no question columns; a Canvas Student Analysis export heads each question \
|
||||
`<id>: <question text>`. Did you export the Item Analysis instead?",
|
||||
path.display()
|
||||
)]));
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
align_questions(&mut questions, record, catalog, &mut warnings);
|
||||
|
||||
// Build the answer-text lookup once per question: normalized option text to
|
||||
// option letter.
|
||||
let mut answer_lookup: BTreeMap<usize, BTreeMap<String, String>> = BTreeMap::new();
|
||||
let mut item_meta: BTreeMap<usize, (Option<String>, Vec<String>)> = BTreeMap::new();
|
||||
if let (Some(cat), Some(rec)) = (catalog, record) {
|
||||
for (qi, q) in questions.iter().enumerate() {
|
||||
let Some(number) = q.item_number else {
|
||||
continue;
|
||||
};
|
||||
let Some(placement) = rec.placement(number) else {
|
||||
continue;
|
||||
};
|
||||
let Some(entry) = cat.get(&placement.item) else {
|
||||
continue;
|
||||
};
|
||||
let mut map = BTreeMap::new();
|
||||
for opt in &entry.item.options {
|
||||
map.insert(normalize(&opt.text), opt.id.clone());
|
||||
}
|
||||
answer_lookup.insert(qi, map);
|
||||
item_meta.insert(qi, (Some(placement.item.clone()), placement.key.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let admin = administration_id(&ctx.course, &ctx.term, &ctx.assessment_id);
|
||||
let mut set = ResponseSet::new();
|
||||
let mut unmatched_answers: BTreeMap<(u32, String), usize> = BTreeMap::new();
|
||||
|
||||
for rec in reader.records() {
|
||||
let rec = rec.map_err(|e| Error::Csv {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
let cell = |i: Option<usize>| -> Option<String> {
|
||||
i.and_then(|i| rec.get(i))
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
};
|
||||
|
||||
let sid = cell(sis_col).or_else(|| cell(id_col));
|
||||
let name = cell(name_col);
|
||||
if sid.is_none() && name.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Canvas emits a "Test Student" row for anyone who previewed the quiz.
|
||||
if name.as_deref() == Some("Test Student") {
|
||||
continue;
|
||||
}
|
||||
let student_key = sid
|
||||
.clone()
|
||||
.or_else(|| name.clone())
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let section = cell(section_col);
|
||||
let submitted = cell(submitted_col);
|
||||
let attempt = cell(attempt_col).and_then(|a| a.parse::<u32>().ok());
|
||||
// Only the graded attempt is exported per row, but a student who never
|
||||
// submitted still gets a row; those carry no responses.
|
||||
if submitted.is_none() && attempt.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (qi, q) in questions.iter().enumerate() {
|
||||
let Some(number) = q.item_number else {
|
||||
continue;
|
||||
};
|
||||
let answer_text = rec.get(q.text_index).map(|s| s.trim()).unwrap_or("");
|
||||
let score = q
|
||||
.points_index
|
||||
.and_then(|i| rec.get(i))
|
||||
.and_then(|s| s.trim().parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
let points = q.points_possible;
|
||||
let credit = if points > 0.0 { score / points } else { 0.0 };
|
||||
|
||||
let mut selected = Vec::new();
|
||||
if !answer_text.is_empty() {
|
||||
let normalized = normalize(answer_text);
|
||||
match answer_lookup.get(&qi).and_then(|m| m.get(&normalized)) {
|
||||
Some(letter) => selected.push(letter.clone()),
|
||||
None => {
|
||||
*unmatched_answers
|
||||
.entry((number, q.canvas_id.clone()))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (item_ref, key) = item_meta.get(&qi).cloned().unwrap_or((None, Vec::new()));
|
||||
|
||||
let correct = if answer_text.is_empty() {
|
||||
None
|
||||
} else if points > 0.0 {
|
||||
Some(credit >= 0.999)
|
||||
} else if !key.is_empty() && !selected.is_empty() {
|
||||
Some(selected == key)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
set.rows.push(Response {
|
||||
administration_id: admin.clone(),
|
||||
course: ctx.course.clone(),
|
||||
term: ctx.term.clone(),
|
||||
assessment_id: ctx.assessment_id.clone(),
|
||||
date: ctx.date,
|
||||
form: ctx.form.clone(),
|
||||
student_key: student_key.clone(),
|
||||
sid: sid.clone(),
|
||||
name: name.clone(),
|
||||
email: None,
|
||||
section: section.clone(),
|
||||
item_number: number,
|
||||
item_ref,
|
||||
item_version: None,
|
||||
selected,
|
||||
eliminated: Vec::new(),
|
||||
correct,
|
||||
credit,
|
||||
points_possible: points,
|
||||
score,
|
||||
response_time_seconds: None,
|
||||
level: None,
|
||||
learning_objectives: Vec::new(),
|
||||
topics: Vec::new(),
|
||||
bonus: false,
|
||||
dropped: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for ((number, canvas_id), count) in unmatched_answers {
|
||||
warnings.push(format!(
|
||||
"question {number} (Canvas id {canvas_id}): {count} answer(s) did not match any option \
|
||||
text, so those responses have a score but no option letter; distractor analysis for \
|
||||
this item will be incomplete. The usual cause is the option text being edited in \
|
||||
Canvas after import"
|
||||
));
|
||||
}
|
||||
|
||||
if set.rows.is_empty() {
|
||||
warnings.push(
|
||||
"no student rows were found; the export may contain only the header, or every row \
|
||||
may be an unsubmitted attempt"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
set.warnings.extend(warnings);
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
/// Assigns an item number to each question column.
|
||||
///
|
||||
/// Matching on stem text is preferred over column order because Canvas shuffles
|
||||
/// questions when the quiz says to, and the export follows the shuffled order.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `questions` - the columns, updated in place.
|
||||
/// * `record` - the assessment record.
|
||||
/// * `catalog` - the loaded course.
|
||||
/// * `warnings` - collects anything ambiguous.
|
||||
fn align_questions(
|
||||
questions: &mut [QuestionColumn],
|
||||
record: Option<&AssessmentFile>,
|
||||
catalog: Option<&Catalog>,
|
||||
warnings: &mut Vec<String>,
|
||||
) {
|
||||
let Some(rec) = record else {
|
||||
// Without a record, the only sensible assumption is column order.
|
||||
for (i, q) in questions.iter_mut().enumerate() {
|
||||
q.item_number = Some(i as u32 + 1);
|
||||
}
|
||||
warnings.push(
|
||||
"no assessment record was supplied, so questions were matched to item numbers by \
|
||||
column order; pass --assessment to match on question text instead"
|
||||
.to_string(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Normalized stem to item number, when the catalog is available.
|
||||
let mut by_stem: BTreeMap<String, Vec<u32>> = BTreeMap::new();
|
||||
if let Some(cat) = catalog {
|
||||
for p in &rec.items {
|
||||
if let Some(entry) = cat.get(&p.item) {
|
||||
by_stem
|
||||
.entry(normalize(&entry.item.stem))
|
||||
.or_default()
|
||||
.push(p.number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut used: Vec<u32> = Vec::new();
|
||||
let mut unmatched: Vec<String> = Vec::new();
|
||||
|
||||
for (i, q) in questions.iter_mut().enumerate() {
|
||||
let normalized = normalize(&q.question_text);
|
||||
let candidates = by_stem.get(&normalized);
|
||||
match candidates {
|
||||
Some(numbers) => {
|
||||
// Prefer a number not already claimed, so two items sharing a stem
|
||||
// do not collapse onto one.
|
||||
match numbers.iter().find(|n| !used.contains(n)) {
|
||||
Some(n) => {
|
||||
q.item_number = Some(*n);
|
||||
used.push(*n);
|
||||
}
|
||||
None => {
|
||||
q.item_number = Some(numbers[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Fall back to position, which is right for an unshuffled quiz.
|
||||
let fallback = rec.items.get(i).map(|p| p.number).unwrap_or(i as u32 + 1);
|
||||
if !used.contains(&fallback) {
|
||||
used.push(fallback);
|
||||
}
|
||||
q.item_number = Some(fallback);
|
||||
// Report the Canvas question id, not just a count. The id is what
|
||||
// you can search for in Canvas to see which question this was, and
|
||||
// an unmatched column usually means the text was edited there after
|
||||
// import — so being able to find it is the whole remedy.
|
||||
unmatched.push(format!("{} (assumed question {fallback})", q.canvas_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !unmatched.is_empty() {
|
||||
warnings.push(format!(
|
||||
"{} of {} question column(s) could not be matched to the assessment record by question \
|
||||
text and were matched by column order instead. Canvas question id(s): {}. Check that \
|
||||
the record describes this quiz, and that the question text was not edited in Canvas \
|
||||
after import",
|
||||
unmatched.len(),
|
||||
questions.len(),
|
||||
unmatched.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Guesses a date from a Canvas timestamp column.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `s` - the timestamp, e.g. `2026-04-01 14:03:22 UTC`.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The date, when the leading token parses as one.
|
||||
pub fn date_from_timestamp(s: &str) -> Option<Date> {
|
||||
s.split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn recognizes_question_headers() {
|
||||
assert_eq!(
|
||||
split_question_header("123456: Which enzyme catalyzes the step?"),
|
||||
Some((
|
||||
"123456".to_string(),
|
||||
"Which enzyme catalyzes the step?".to_string()
|
||||
))
|
||||
);
|
||||
assert_eq!(split_question_header("name"), None);
|
||||
assert_eq!(split_question_header("n correct"), None);
|
||||
// A colon in prose is not a question column.
|
||||
assert_eq!(split_question_header("Note: read carefully"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_survives_a_qti_round_trip() {
|
||||
// Option text authored as `K#sub[m] increases` is exported as HTML and
|
||||
// comes back from Canvas wrapped in tags. Both must normalize to the same
|
||||
// thing as the HTML we sent, or letters cannot be recovered.
|
||||
let sent = "K<sub>m</sub> increases";
|
||||
let returned = "<p>K<sub>m</sub> increases</p>";
|
||||
assert_eq!(normalize(sent), normalize(returned));
|
||||
assert_eq!(normalize(returned), "k m increases");
|
||||
assert_eq!(normalize("K m increases"), "k m increases");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_ignores_punctuation_and_case() {
|
||||
assert_eq!(
|
||||
normalize("The Rate, Increases!"),
|
||||
normalize("the rate increases")
|
||||
);
|
||||
assert_eq!(normalize(" spaced out "), "spaced out");
|
||||
assert_eq!(normalize("<b>bold</b> text"), "bold text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_yield_dates() {
|
||||
assert_eq!(
|
||||
date_from_timestamp("2026-04-01 14:03:22 UTC").map(|d| d.to_string()),
|
||||
Some("2026-04-01".to_string())
|
||||
);
|
||||
assert_eq!(date_from_timestamp("not a date"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_column_order_without_a_record() {
|
||||
let mut questions = vec![
|
||||
QuestionColumn {
|
||||
text_index: 8,
|
||||
points_index: Some(9),
|
||||
canvas_id: "1".into(),
|
||||
question_text: "Q one".into(),
|
||||
points_possible: 1.0,
|
||||
item_number: None,
|
||||
},
|
||||
QuestionColumn {
|
||||
text_index: 10,
|
||||
points_index: Some(11),
|
||||
canvas_id: "2".into(),
|
||||
question_text: "Q two".into(),
|
||||
points_possible: 1.0,
|
||||
item_number: None,
|
||||
},
|
||||
];
|
||||
let mut warnings = Vec::new();
|
||||
align_questions(&mut questions, None, None, &mut warnings);
|
||||
assert_eq!(questions[0].item_number, Some(1));
|
||||
assert_eq!(questions[1].item_number, Some(2));
|
||||
assert_eq!(warnings.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_realistic_export() {
|
||||
let dir = std::env::temp_dir().join(format!("cb-canvas-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("analysis.csv");
|
||||
std::fs::write(
|
||||
&path,
|
||||
"name,id,sis_id,section,submitted,attempt,\
|
||||
1001: Which enzyme?,1.0,1002: Which pathway?,1.0,n correct,n incorrect,score\n\
|
||||
Ada Lovelace,9001,1234567,L01,2026-04-01 14:00:00 UTC,1,Hexokinase,1.0,Glycolysis,\
|
||||
1.0,2,0,2.0\n\
|
||||
Alan Turing,9002,7654321,L01,2026-04-01 14:05:00 UTC,1,Pyruvate kinase,0.0,\
|
||||
Glycolysis,1.0,1,1,1.0\n\
|
||||
Test Student,9999,,L01,2026-04-01 13:00:00 UTC,1,Hexokinase,1.0,Glycolysis,1.0,2,0,\
|
||||
2.0\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ctx = Context {
|
||||
course: "BIOSC1540".into(),
|
||||
term: "2026S".into(),
|
||||
assessment_id: "quiz-1".into(),
|
||||
date: None,
|
||||
form: None,
|
||||
};
|
||||
let set = ingest(&path, &ctx, None, None).unwrap();
|
||||
|
||||
// Two real students, two questions each. The preview row is discarded.
|
||||
assert_eq!(set.rows.len(), 4, "{:?}", set.warnings);
|
||||
assert!(set
|
||||
.rows
|
||||
.iter()
|
||||
.all(|r| r.name.as_deref() != Some("Test Student")));
|
||||
assert_eq!(set.scored_total("1234567"), 2.0);
|
||||
assert_eq!(set.scored_total("7654321"), 1.0);
|
||||
|
||||
let alan_q1 = set
|
||||
.rows
|
||||
.iter()
|
||||
.find(|r| r.student_key == "7654321" && r.item_number == 1)
|
||||
.unwrap();
|
||||
assert_eq!(alan_q1.correct, Some(false));
|
||||
assert_eq!(alan_q1.credit, 0.0);
|
||||
// No catalog was supplied, so no letter could be recovered.
|
||||
assert!(alan_q1.selected.is_empty());
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_the_wrong_export_kind() {
|
||||
let dir = std::env::temp_dir().join(format!("cb-canvas-bad-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("item.csv");
|
||||
std::fs::write(&path, "question,discrimination index\nQ1,0.4\n").unwrap();
|
||||
let ctx = Context {
|
||||
course: "C".into(),
|
||||
term: "T".into(),
|
||||
assessment_id: "a".into(),
|
||||
date: None,
|
||||
form: None,
|
||||
};
|
||||
let err = ingest(&path, &ctx, None, None).unwrap_err();
|
||||
assert!(err.to_string().contains("Student Analysis"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user