fix: improve canvas export
This commit is contained in:
+14
-2
@@ -39,12 +39,24 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
|
||||
let opts = qti::QtiOptions {
|
||||
form: form.clone(),
|
||||
include_feedback: !no_feedback,
|
||||
shuffle_in_canvas: record.assessment.shuffle.unwrap_or(false),
|
||||
attempts: record.assessment.attempts.unwrap_or(1),
|
||||
shuffle_in_canvas: record.assessment.shuffle.unwrap_or(true),
|
||||
// No per-assessment attempts value falls back to unlimited, the
|
||||
// same default `QtiOptions::default()` carries. Keeping this in
|
||||
// step with the struct default avoids a record without an
|
||||
// `attempts:` silently becoming single-attempt here while the
|
||||
// library considers the default to be unlimited.
|
||||
attempts: record.assessment.attempts.unwrap_or(-1),
|
||||
scoring_policy: record
|
||||
.assessment
|
||||
.scoring_policy
|
||||
.unwrap_or(coursebank::assessment::ScoringPolicy::KeepHighest),
|
||||
// The remaining fields drive `assessment_meta.xml` (quiz type,
|
||||
// results visibility, correct-answer display, one-question-at-a-
|
||||
// time, timing, publish state). This command exposes no flags for
|
||||
// them yet, so take the library defaults: a formative graded quiz
|
||||
// that lets students review responses and the correct answer, and
|
||||
// imports unpublished.
|
||||
..qti::QtiOptions::default()
|
||||
};
|
||||
let package = qti::build(&catalog, &record, &opts)?;
|
||||
let path = out
|
||||
|
||||
+404
-40
@@ -46,6 +46,14 @@ const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2";
|
||||
/// The content packaging schema location.
|
||||
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
|
||||
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
|
||||
/// The Canvas metadata namespace, used by `assessment_meta.xml`. Canvas stores
|
||||
/// nearly every quiz setting here (scoring policy, shuffle, results visibility,
|
||||
/// attempts, publish state); the QTI file itself carries only the questions and
|
||||
/// `cc_maxattempts`.
|
||||
const CANVAS_NS: &str = "http://canvas.instructure.com/xsd/cccv1p0";
|
||||
/// The schema location Canvas emits alongside `CANVAS_NS`.
|
||||
const CANVAS_SCHEMA: &str = "http://canvas.instructure.com/xsd/cccv1p0 \
|
||||
https://canvas.instructure.com/xsd/cccv1p0.xsd";
|
||||
|
||||
// A very small XML tree
|
||||
|
||||
@@ -157,10 +165,10 @@ fn escape_attr(s: &str) -> String {
|
||||
/// forms Canvas's MathJax recognizes.
|
||||
///
|
||||
/// Canvas loads MathJax and typesets a text field only when it finds one of its
|
||||
/// own delimiters: `\( … \)` inline, or `$$ … $$` as a display block. Bare `$ … $`
|
||||
/// is not a delimiter Canvas reads, so the tool's authoring convention (`$ … $`
|
||||
/// own delimiters: `\( ... \)` inline, or `$$ ... $$` as a display block. Bare `$ ... $`
|
||||
/// is not a delimiter Canvas reads, so the tool's authoring convention (`$ ... $`
|
||||
/// inline) would otherwise reach the quiz as literal dollar-sign text. Here inline
|
||||
/// `$ … $` becomes `\( … \)` and display `$$ … $$` is left as it is.
|
||||
/// `$ ... $` becomes `\( ... \)` and display `$$ ... $$` is left as it is.
|
||||
///
|
||||
/// The split runs before inline markup, the same way the site export handles it,
|
||||
/// so a subscript like `$q_p$` is not read as an emphasis span. Apart from the
|
||||
@@ -248,9 +256,62 @@ fn option_feedback<'a>(choice: &'a crate::item::Choice, opts: &QtiOptions) -> Op
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders `cc_maxattempts` the way Canvas's QTI dialect actually reads it: a
|
||||
/// plain decimal count for a fixed number of attempts, or the literal string
|
||||
/// `unlimited` for unbounded attempts.
|
||||
///
|
||||
/// Canvas does not treat `-1` (this module's in-memory sentinel for "unlimited",
|
||||
/// see [`QtiOptions::attempts`]) as a number meaning unlimited. Writing it out as
|
||||
/// `-1` produces XML that still imports without error, so this is exactly the
|
||||
/// kind of silent-misbehavior trap the module-level docs warn about: the quiz
|
||||
/// comes in looking fine and only turns out wrong once a student hits the
|
||||
/// attempt limit. Every other non-negative value round-trips as a plain number.
|
||||
fn max_attempts_field(attempts: i64) -> String {
|
||||
if attempts < 0 {
|
||||
"unlimited".to_string()
|
||||
} else {
|
||||
attempts.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Package construction
|
||||
|
||||
/// The Canvas quiz type, written to `assessment_meta.xml` as `<quiz_type>`.
|
||||
///
|
||||
/// [`QuizType::Assignment`] is an ordinary graded quiz. A graded survey scores
|
||||
/// on completion rather than correctness, so it cannot show right/wrong marks;
|
||||
/// don't pair it with per-option feedback that presumes a correct answer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum QuizType {
|
||||
/// A graded quiz (the default).
|
||||
Assignment,
|
||||
/// An ungraded practice quiz.
|
||||
PracticeQuiz,
|
||||
/// A graded survey: points for completing, not for correctness.
|
||||
GradedSurvey,
|
||||
/// An ungraded survey.
|
||||
Survey,
|
||||
}
|
||||
|
||||
impl QuizType {
|
||||
/// The `<quiz_type>` string Canvas expects.
|
||||
fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
QuizType::Assignment => "assignment",
|
||||
QuizType::PracticeQuiz => "practice_quiz",
|
||||
QuizType::GradedSurvey => "graded_survey",
|
||||
QuizType::Survey => "survey",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for a QTI export.
|
||||
///
|
||||
/// The fields fall into two groups. A few (`form`, `include_feedback`) shape the
|
||||
/// QTI questions themselves. The rest map onto `assessment_meta.xml`, the Canvas
|
||||
/// sidecar file that actually carries the quiz's Details-tab settings: putting a
|
||||
/// setting here is what makes it survive import, since the QTI file cannot hold
|
||||
/// it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct QtiOptions {
|
||||
/// Which form's option order to use.
|
||||
@@ -260,13 +321,37 @@ pub struct QtiOptions {
|
||||
/// immediately.
|
||||
pub include_feedback: bool,
|
||||
/// Whether to let Canvas shuffle answers on top of the form's own order.
|
||||
/// Emitted as `<shuffle_answers>` in the meta file.
|
||||
pub shuffle_in_canvas: bool,
|
||||
/// Maximum attempts; `-1` for unlimited. More than one attempt also switches
|
||||
/// per-option feedback from the misconception to the hint, so a formative
|
||||
/// quiz nudges rather than reveals.
|
||||
/// quiz nudges rather than reveals. Emitted as `cc_maxattempts` in the QTI
|
||||
/// file and `<allowed_attempts>` in the meta file.
|
||||
pub attempts: i64,
|
||||
/// How repeated attempts are scored.
|
||||
/// How repeated attempts are scored. Emitted as `<scoring_policy>`.
|
||||
pub scoring_policy: ScoringPolicy,
|
||||
/// The Canvas quiz type. Emitted as `<quiz_type>`.
|
||||
pub quiz_type: QuizType,
|
||||
/// Whether students may review their own submissions and see which answers
|
||||
/// were marked wrong — the "Let Students See Their Quiz Responses" checkbox.
|
||||
/// This is the switch that makes the per-answer hints visible at all, so it
|
||||
/// defaults on. Emitted as `<hide_results>`: empty when true, `always` when
|
||||
/// false.
|
||||
pub let_students_see_responses: bool,
|
||||
/// Whether students see the keyed-correct answer highlighted on review — the
|
||||
/// "Let Students See The Correct Answers" checkbox. Only has an effect when
|
||||
/// `let_students_see_responses` is true. Emitted as `<show_correct_answers>`.
|
||||
pub show_correct_answers: bool,
|
||||
/// Whether Canvas shows one question per page. Emitted as
|
||||
/// `<one_question_at_a_time>`.
|
||||
pub one_question_at_a_time: bool,
|
||||
/// A time limit in minutes, or `None` for untimed. Emitted as `<time_limit>`.
|
||||
pub time_limit_minutes: Option<u32>,
|
||||
/// Whether the imported quiz is published (available to students) on arrival.
|
||||
/// Defaults off so an import never surprises a class with a live quiz before
|
||||
/// the instructor has looked at it. Emitted as `<available>` and the
|
||||
/// assignment's `<workflow_state>`.
|
||||
pub published: bool,
|
||||
}
|
||||
|
||||
impl Default for QtiOptions {
|
||||
@@ -279,20 +364,44 @@ impl Default for QtiOptions {
|
||||
shuffle_options: false,
|
||||
},
|
||||
include_feedback: true,
|
||||
shuffle_in_canvas: false,
|
||||
attempts: 1,
|
||||
shuffle_in_canvas: true,
|
||||
// Unlimited attempts, which also selects the hint-only feedback path
|
||||
// in `option_feedback` rather than the full reveal, so a quiz is
|
||||
// formative by default. Pass `attempts: 1` explicitly for a
|
||||
// single-shot summative export.
|
||||
attempts: -1,
|
||||
scoring_policy: ScoringPolicy::KeepHighest,
|
||||
// The remaining defaults describe the formative graded quiz worked
|
||||
// out with the course staff: a normal graded quiz, students may
|
||||
// review their responses and see the correct answer, all questions
|
||||
// on one page, untimed, and left unpublished for the instructor to
|
||||
// publish after review.
|
||||
quiz_type: QuizType::Assignment,
|
||||
let_students_see_responses: true,
|
||||
show_correct_answers: true,
|
||||
one_question_at_a_time: false,
|
||||
time_limit_minutes: None,
|
||||
published: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A rendered QTI package, ready to write.
|
||||
///
|
||||
/// The archive mirrors a Canvas Common Cartridge quiz export: the manifest sits
|
||||
/// at the root, and the quiz QTI file and its `assessment_meta.xml` sidecar sit
|
||||
/// together in a folder named for the assessment identifier. `quiz_filename` and
|
||||
/// `meta_filename` are the in-archive paths, including that folder.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Package {
|
||||
/// The name of the quiz XML file inside the archive.
|
||||
/// The in-archive path of the quiz XML file (e.g. `g.../a1_1.xml`).
|
||||
pub quiz_filename: String,
|
||||
/// The quiz XML.
|
||||
/// The quiz QTI XML.
|
||||
pub quiz_xml: String,
|
||||
/// The in-archive path of the settings sidecar (e.g. `g.../assessment_meta.xml`).
|
||||
pub meta_filename: String,
|
||||
/// The `assessment_meta.xml` XML, carrying the Canvas quiz settings.
|
||||
pub meta_xml: String,
|
||||
/// The manifest XML.
|
||||
pub manifest_xml: String,
|
||||
}
|
||||
@@ -309,10 +418,12 @@ impl Package {
|
||||
/// Returns [`Error::Io`] on a write failure.
|
||||
pub fn write_zip(&self, path: &std::path::Path) -> Result<()> {
|
||||
let mut z = ZipBuilder::new();
|
||||
// Canvas only recognizes the archive as QTI when the manifest sits at the
|
||||
// root rather than inside a directory.
|
||||
// Canvas only recognizes the archive as a course/quiz package when the
|
||||
// manifest sits at the root. The quiz and its settings sidecar live in a
|
||||
// per-assessment folder, the layout Canvas's own export uses.
|
||||
z.add_text("imsmanifest.xml", &self.manifest_xml);
|
||||
z.add_text(&self.quiz_filename, &self.quiz_xml);
|
||||
z.add_text(&self.meta_filename, &self.meta_xml);
|
||||
z.write_to(path)
|
||||
}
|
||||
}
|
||||
@@ -340,6 +451,11 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
||||
|
||||
let mut problems = Vec::new();
|
||||
let mut items = Vec::new();
|
||||
// Accumulated to fill `<points_possible>` in the meta file. Canvas recomputes
|
||||
// this from the question points on import, so it is advisory, but it should
|
||||
// still agree with the questions. Bonus placements are excluded, matching how
|
||||
// they sit outside the graded total.
|
||||
let mut total_points = 0.0_f64;
|
||||
|
||||
for placement in select::layout(record, &opts.form) {
|
||||
let entry = catalog.require(&placement.item)?;
|
||||
@@ -357,6 +473,9 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
||||
let points = placement
|
||||
.points
|
||||
.unwrap_or_else(|| item.points(default_points));
|
||||
if !placement.bonus {
|
||||
total_points += points;
|
||||
}
|
||||
items.push(build_item(
|
||||
&record.assessment.id,
|
||||
&placement.item,
|
||||
@@ -370,18 +489,14 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
||||
return Err(Error::Invalid(problems));
|
||||
}
|
||||
|
||||
let metadata = vec![
|
||||
metadata_field("cc_maxattempts", &opts.attempts.to_string()),
|
||||
metadata_field("cc_quiz_scoring_policy", opts.scoring_policy.as_str()),
|
||||
metadata_field(
|
||||
"cc_shuffle_answers",
|
||||
if opts.shuffle_in_canvas {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
},
|
||||
),
|
||||
];
|
||||
// The QTI file carries only `cc_maxattempts`, matching what Canvas's own
|
||||
// export writes. Scoring policy, shuffle, and the results-visibility settings
|
||||
// live in `assessment_meta.xml` instead; writing them here too would create a
|
||||
// second, potentially disagreeing source of truth.
|
||||
let metadata = vec![metadata_field(
|
||||
"cc_maxattempts",
|
||||
&max_attempts_field(opts.attempts),
|
||||
)];
|
||||
|
||||
let title = if record.forms.len() > 1 {
|
||||
format!("{} (form {})", record.assessment.title, opts.form.id)
|
||||
@@ -405,17 +520,33 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
||||
.attr("xsi:schemaLocation", QTI_SCHEMA)
|
||||
.child(assessment);
|
||||
|
||||
let quiz_filename = format!("{}.xml", slug_filename(&record.assessment.id));
|
||||
// Canvas's layout: a folder named for the assessment identifier holds both
|
||||
// the quiz QTI file and its settings sidecar.
|
||||
let quiz_dir = assessment_id.clone();
|
||||
let quiz_href = format!("{quiz_dir}/{}.xml", slug_filename(&record.assessment.id));
|
||||
let meta_href = format!("{quiz_dir}/assessment_meta.xml");
|
||||
let meta_resource_id = format!("{assessment_id}_meta");
|
||||
|
||||
// The assignment wrapper Canvas creates for a graded quiz needs its own
|
||||
// stable identifier, derived like every other id here.
|
||||
let assignment_id = qti_id(&format!("{}/assignment", record.assessment.id));
|
||||
|
||||
let meta = build_assessment_meta(&assessment_id, &assignment_id, &title, total_points, opts);
|
||||
|
||||
let manifest = build_manifest(
|
||||
&quiz_filename,
|
||||
&quiz_href,
|
||||
&meta_href,
|
||||
&assessment_id,
|
||||
&meta_resource_id,
|
||||
&title,
|
||||
&record.assessment.id,
|
||||
);
|
||||
|
||||
Ok(Package {
|
||||
quiz_filename,
|
||||
quiz_filename: quiz_href,
|
||||
quiz_xml: root.document(),
|
||||
meta_filename: meta_href,
|
||||
meta_xml: meta.document(),
|
||||
manifest_xml: manifest.document(),
|
||||
})
|
||||
}
|
||||
@@ -726,15 +857,29 @@ fn metadata_field(label: &str, entry: &str) -> Node {
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `quiz_filename` - the quiz XML file name.
|
||||
/// * `assessment_id` - the assessment identifier, reused as the resource id.
|
||||
/// * `quiz_href` - the in-archive path of the quiz XML file.
|
||||
/// * `meta_href` - the in-archive path of the settings sidecar.
|
||||
/// * `assessment_id` - the assessment identifier, reused as the quiz resource id.
|
||||
/// * `meta_resource_id` - the resource id for the sidecar.
|
||||
/// * `title` - the human title.
|
||||
/// * `salt` - salts the manifest identifier.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The manifest element.
|
||||
fn build_manifest(quiz_filename: &str, assessment_id: &str, title: &str, salt: &str) -> Node {
|
||||
///
|
||||
/// The quiz resource declares the sidecar as a `<dependency>`, and the sidecar is
|
||||
/// its own `learning-application-resource`, the wiring Canvas uses to find
|
||||
/// `assessment_meta.xml` for a quiz. Without the dependency Canvas imports the
|
||||
/// questions but ignores the settings file.
|
||||
fn build_manifest(
|
||||
quiz_href: &str,
|
||||
meta_href: &str,
|
||||
assessment_id: &str,
|
||||
meta_resource_id: &str,
|
||||
title: &str,
|
||||
salt: &str,
|
||||
) -> Node {
|
||||
let lom = Node::new("imsmd:lom").child(
|
||||
Node::new("imsmd:general").child(
|
||||
Node::new("imsmd:title").child(
|
||||
@@ -745,6 +890,22 @@ fn build_manifest(quiz_filename: &str, assessment_id: &str, title: &str, salt: &
|
||||
),
|
||||
);
|
||||
|
||||
let quiz_resource = Node::new("resource")
|
||||
.attr("identifier", assessment_id)
|
||||
.attr("type", "imsqti_xmlv1p2/imscc_xmlv1p1/assessment")
|
||||
.attr("href", quiz_href)
|
||||
.child(Node::new("file").attr("href", quiz_href))
|
||||
.child(Node::new("dependency").attr("identifierref", meta_resource_id));
|
||||
|
||||
let meta_resource = Node::new("resource")
|
||||
.attr("identifier", meta_resource_id)
|
||||
.attr(
|
||||
"type",
|
||||
"associatedcontent/imscc_xmlv1p1/learning-application-resource",
|
||||
)
|
||||
.attr("href", meta_href)
|
||||
.child(Node::new("file").attr("href", meta_href));
|
||||
|
||||
Node::new("manifest")
|
||||
.attr("identifier", format!("man{}", qti_id(salt)))
|
||||
.attr("xmlns", IMSCP_NS)
|
||||
@@ -765,16 +926,112 @@ fn build_manifest(quiz_filename: &str, assessment_id: &str, title: &str, salt: &
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Node::new("resources").child(
|
||||
Node::new("resource")
|
||||
.attr("identifier", assessment_id)
|
||||
.attr("type", "imsqti_xmlv1p2")
|
||||
.attr("href", quiz_filename)
|
||||
.child(Node::new("file").attr("href", quiz_filename)),
|
||||
),
|
||||
Node::new("resources")
|
||||
.child(quiz_resource)
|
||||
.child(meta_resource),
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the `assessment_meta.xml` element: the Canvas sidecar that carries the
|
||||
/// quiz's Details-tab settings.
|
||||
///
|
||||
/// The QTI file holds the questions and `cc_maxattempts` and nothing else, so
|
||||
/// this is where scoring policy, answer shuffling, results visibility, the quiz
|
||||
/// type, timing, and publish state actually live. The element and child order
|
||||
/// follow a Canvas-emitted file; Canvas is order-tolerant on import, but matching
|
||||
/// its shape keeps the output diffable against a real export.
|
||||
///
|
||||
/// Two mappings are worth stating. `<hide_results>` is the inverse of the "Let
|
||||
/// Students See Their Quiz Responses" checkbox: empty means they may review,
|
||||
/// `always` means they never can — so students only ever see the per-answer hints
|
||||
/// when it is empty. And `<allowed_attempts>` takes the raw `-1` sentinel for
|
||||
/// unlimited (unlike the QTI file's `cc_maxattempts`, which spells it `unlimited`).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `quiz_identifier` - the quiz identifier, reused as `quiz_identifierref`.
|
||||
/// * `assignment_identifier` - the wrapping assignment's identifier.
|
||||
/// * `title` - the human title.
|
||||
/// * `points_possible` - the summed non-bonus points.
|
||||
/// * `opts` - export options.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// The `<quiz>` element.
|
||||
fn build_assessment_meta(
|
||||
quiz_identifier: &str,
|
||||
assignment_identifier: &str,
|
||||
title: &str,
|
||||
points_possible: f64,
|
||||
opts: &QtiOptions,
|
||||
) -> Node {
|
||||
let bool_str = |b: bool| if b { "true" } else { "false" };
|
||||
// Canvas prints whole point totals without a decimal (e.g. `8`), matching its
|
||||
// own export.
|
||||
let points = format!("{points_possible}");
|
||||
let workflow_state = if opts.published {
|
||||
"published"
|
||||
} else {
|
||||
"unpublished"
|
||||
};
|
||||
|
||||
// "Let Students See Their Quiz Responses" on -> hide_results empty; off ->
|
||||
// `always` (never show results, which also hides the hints).
|
||||
let hide_results = Node::new("hide_results");
|
||||
let hide_results = if opts.let_students_see_responses {
|
||||
hide_results
|
||||
} else {
|
||||
hide_results.text("always")
|
||||
};
|
||||
|
||||
let time_limit = match opts.time_limit_minutes {
|
||||
Some(m) => Node::new("time_limit").text(m.to_string()),
|
||||
None => Node::new("time_limit"),
|
||||
};
|
||||
|
||||
let assignment = Node::new("assignment")
|
||||
.attr("identifier", assignment_identifier)
|
||||
.child(Node::new("title").text(title))
|
||||
.child(Node::new("due_at"))
|
||||
.child(Node::new("lock_at"))
|
||||
.child(Node::new("unlock_at"))
|
||||
.child(Node::new("workflow_state").text(workflow_state))
|
||||
.child(Node::new("quiz_identifierref").text(quiz_identifier))
|
||||
.child(Node::new("points_possible").text(points.clone()))
|
||||
.child(Node::new("grading_type").text("points"))
|
||||
.child(Node::new("submission_types").text("online_quiz"))
|
||||
.child(Node::new("omit_from_final_grade").text("false"));
|
||||
|
||||
Node::new("quiz")
|
||||
.attr("identifier", quiz_identifier)
|
||||
.attr("xmlns", CANVAS_NS)
|
||||
.attr("xmlns:xsi", XSI_NS)
|
||||
.attr("xsi:schemaLocation", CANVAS_SCHEMA)
|
||||
.child(Node::new("title").text(title))
|
||||
.child(Node::new("shuffle_answers").text(bool_str(opts.shuffle_in_canvas)))
|
||||
.child(Node::new("scoring_policy").text(opts.scoring_policy.as_str()))
|
||||
.child(hide_results)
|
||||
.child(Node::new("quiz_type").text(opts.quiz_type.as_str()))
|
||||
.child(Node::new("points_possible").text(points))
|
||||
.child(Node::new("require_lockdown_browser").text("false"))
|
||||
.child(Node::new("require_lockdown_browser_for_results").text("false"))
|
||||
.child(Node::new("require_lockdown_browser_monitor").text("false"))
|
||||
.child(Node::new("lockdown_browser_monitor_data"))
|
||||
.child(Node::new("show_correct_answers").text(bool_str(opts.show_correct_answers)))
|
||||
.child(Node::new("anonymous_submissions").text("false"))
|
||||
.child(Node::new("could_be_locked").text("false"))
|
||||
.child(time_limit)
|
||||
.child(Node::new("allowed_attempts").text(opts.attempts.to_string()))
|
||||
.child(Node::new("one_question_at_a_time").text(bool_str(opts.one_question_at_a_time)))
|
||||
.child(Node::new("cant_go_back").text("false"))
|
||||
.child(Node::new("available").text(bool_str(opts.published)))
|
||||
.child(Node::new("one_time_results").text("false"))
|
||||
.child(Node::new("show_correct_answers_last_attempt").text("false"))
|
||||
.child(Node::new("only_visible_to_overrides").text("false"))
|
||||
.child(Node::new("module_locked").text("false"))
|
||||
.child(assignment)
|
||||
}
|
||||
|
||||
/// A deterministic QTI identifier: `g` followed by 32 hex characters.
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -874,11 +1131,71 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_points_at_the_quiz_file() {
|
||||
let m = build_manifest("exam-4.xml", "gabc", "Exam 4", "exam-4").render(0);
|
||||
assert!(m.contains("imsqti_xmlv1p2"));
|
||||
assert!(m.contains("href=\"exam-4.xml\""));
|
||||
fn max_attempts_field_is_unlimited_not_negative_one() {
|
||||
// The bug this guards against: writing the in-memory `-1` sentinel
|
||||
// straight into the XML instead of translating it to the literal
|
||||
// Canvas expects. `-1` imports without error and silently fails to
|
||||
// behave as unlimited.
|
||||
assert_eq!(max_attempts_field(-1), "unlimited");
|
||||
assert_eq!(max_attempts_field(1), "1");
|
||||
assert_eq!(max_attempts_field(3), "3");
|
||||
assert_eq!(max_attempts_field(0), "0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_points_at_the_quiz_and_its_meta() {
|
||||
let m = build_manifest(
|
||||
"gabc/exam-4.xml",
|
||||
"gabc/assessment_meta.xml",
|
||||
"gabc",
|
||||
"gabc_meta",
|
||||
"Exam 4",
|
||||
"exam-4",
|
||||
)
|
||||
.render(0);
|
||||
assert!(m.contains("imsqti_xmlv1p2/imscc_xmlv1p1/assessment"));
|
||||
assert!(m.contains("href=\"gabc/exam-4.xml\""));
|
||||
assert!(m.contains("Exam 4"));
|
||||
// The settings sidecar must be declared and depended on, or Canvas
|
||||
// imports the questions and silently drops every quiz setting.
|
||||
assert!(m.contains("href=\"gabc/assessment_meta.xml\""));
|
||||
assert!(m.contains("learning-application-resource"));
|
||||
assert!(m.contains("<dependency identifierref=\"gabc_meta\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_carries_the_discussed_defaults() {
|
||||
let opts = QtiOptions::default();
|
||||
let m = build_assessment_meta("gquiz", "gassign", "A1.1: Enthalpy", 8.0, &opts).render(0);
|
||||
// The settings that make this a formative, unlimited-attempts quiz.
|
||||
assert!(m.contains("<scoring_policy>keep_highest</scoring_policy>"));
|
||||
assert!(m.contains("<allowed_attempts>-1</allowed_attempts>"));
|
||||
assert!(m.contains("<quiz_type>assignment</quiz_type>"));
|
||||
assert!(m.contains("<show_correct_answers>true</show_correct_answers>"));
|
||||
assert!(m.contains("<shuffle_answers>false</shuffle_answers>"));
|
||||
// Default is unpublished, so an import never goes live unreviewed.
|
||||
assert!(m.contains("<available>false</available>"));
|
||||
assert!(m.contains("<workflow_state>unpublished</workflow_state>"));
|
||||
// Canvas namespace, so the importer recognizes it as quiz settings.
|
||||
assert!(m.contains(CANVAS_NS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_results_tracks_letting_students_see_responses() {
|
||||
// Default (on): hide_results is empty, so students may review and the
|
||||
// per-answer hints are visible.
|
||||
let on = QtiOptions::default();
|
||||
let m = build_assessment_meta("gq", "ga", "T", 1.0, &on).render(0);
|
||||
assert!(m.contains("<hide_results/>"), "{m}");
|
||||
assert!(!m.contains("<hide_results>always"));
|
||||
|
||||
// Off: hide_results is `always`, which also suppresses the hints.
|
||||
let off = QtiOptions {
|
||||
let_students_see_responses: false,
|
||||
..QtiOptions::default()
|
||||
};
|
||||
let m = build_assessment_meta("gq", "ga", "T", 1.0, &off).render(0);
|
||||
assert!(m.contains("<hide_results>always</hide_results>"), "{m}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -959,6 +1276,28 @@ mod tests {
|
||||
assert_eq!(option_feedback(&no_hint, &opts), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_options_export_as_unlimited_with_hints() {
|
||||
// Locks in the two changes together: the default is unlimited
|
||||
// attempts, and that default routes wrong-answer feedback through the
|
||||
// hint rather than the full reveal. A quiz built with `..Default::default()`
|
||||
// and no explicit `attempts` is formative unless the caller opts out.
|
||||
let opts = QtiOptions::default();
|
||||
assert_eq!(opts.attempts, -1);
|
||||
assert_eq!(
|
||||
max_attempts_field(opts.attempts),
|
||||
"unlimited",
|
||||
"the exported cc_maxattempts field must read \"unlimited\", not \"-1\""
|
||||
);
|
||||
|
||||
let choice = distractor();
|
||||
assert_eq!(
|
||||
option_feedback(&choice, &opts),
|
||||
Some("Reconsider what stays constant in an open flask."),
|
||||
"default options should show the hint, not the misconception reveal"
|
||||
);
|
||||
}
|
||||
|
||||
fn catalog(tag: &str) -> Catalog {
|
||||
let dir = std::env::temp_dir().join(format!("cb-qti-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -1075,5 +1414,30 @@ items:
|
||||
// answer rides along as feedback.
|
||||
assert!(pkg.quiz_xml.contains("\\(q_p = \\Delta H\\)"));
|
||||
assert!(pkg.quiz_xml.contains("H = U + PV"));
|
||||
|
||||
// The QTI file carries only cc_maxattempts now; scoring and shuffle moved
|
||||
// to the sidecar.
|
||||
assert!(pkg.quiz_xml.contains("cc_maxattempts"));
|
||||
assert!(
|
||||
!pkg.quiz_xml.contains("cc_quiz_scoring_policy"),
|
||||
"scoring policy belongs in assessment_meta.xml, not the QTI file"
|
||||
);
|
||||
assert!(!pkg.quiz_xml.contains("cc_shuffle_answers"));
|
||||
|
||||
// The package includes a settings sidecar, and the manifest points the
|
||||
// quiz resource at it. The essay's 2 points are excluded from the graded
|
||||
// total only if bonus; here both placements count, so points_possible is
|
||||
// the sum the questions carry.
|
||||
assert!(pkg.meta_filename.ends_with("assessment_meta.xml"));
|
||||
assert!(
|
||||
pkg.meta_xml
|
||||
.contains("<scoring_policy>keep_highest</scoring_policy>")
|
||||
);
|
||||
assert!(
|
||||
pkg.meta_xml
|
||||
.contains("<allowed_attempts>-1</allowed_attempts>")
|
||||
);
|
||||
assert!(pkg.manifest_xml.contains(&pkg.meta_filename));
|
||||
assert!(pkg.manifest_xml.contains("<dependency identifierref="));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user