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 {
|
let opts = qti::QtiOptions {
|
||||||
form: form.clone(),
|
form: form.clone(),
|
||||||
include_feedback: !no_feedback,
|
include_feedback: !no_feedback,
|
||||||
shuffle_in_canvas: record.assessment.shuffle.unwrap_or(false),
|
shuffle_in_canvas: record.assessment.shuffle.unwrap_or(true),
|
||||||
attempts: record.assessment.attempts.unwrap_or(1),
|
// 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
|
scoring_policy: record
|
||||||
.assessment
|
.assessment
|
||||||
.scoring_policy
|
.scoring_policy
|
||||||
.unwrap_or(coursebank::assessment::ScoringPolicy::KeepHighest),
|
.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 package = qti::build(&catalog, &record, &opts)?;
|
||||||
let path = out
|
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.
|
/// The content packaging schema location.
|
||||||
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
|
const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
|
||||||
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.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
|
// A very small XML tree
|
||||||
|
|
||||||
@@ -157,10 +165,10 @@ fn escape_attr(s: &str) -> String {
|
|||||||
/// forms Canvas's MathJax recognizes.
|
/// forms Canvas's MathJax recognizes.
|
||||||
///
|
///
|
||||||
/// Canvas loads MathJax and typesets a text field only when it finds one of its
|
/// Canvas loads MathJax and typesets a text field only when it finds one of its
|
||||||
/// own delimiters: `\( … \)` inline, or `$$ … $$` as a display block. Bare `$ … $`
|
/// own delimiters: `\( ... \)` inline, or `$$ ... $$` as a display block. Bare `$ ... $`
|
||||||
/// is not a delimiter Canvas reads, so the tool's authoring convention (`$ … $`
|
/// 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
|
/// 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,
|
/// 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
|
/// 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
|
// 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.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct QtiOptions {
|
pub struct QtiOptions {
|
||||||
/// Which form's option order to use.
|
/// Which form's option order to use.
|
||||||
@@ -260,13 +321,37 @@ pub struct QtiOptions {
|
|||||||
/// immediately.
|
/// immediately.
|
||||||
pub include_feedback: bool,
|
pub include_feedback: bool,
|
||||||
/// Whether to let Canvas shuffle answers on top of the form's own order.
|
/// 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,
|
pub shuffle_in_canvas: bool,
|
||||||
/// Maximum attempts; `-1` for unlimited. More than one attempt also switches
|
/// Maximum attempts; `-1` for unlimited. More than one attempt also switches
|
||||||
/// per-option feedback from the misconception to the hint, so a formative
|
/// 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,
|
pub attempts: i64,
|
||||||
/// How repeated attempts are scored.
|
/// How repeated attempts are scored. Emitted as `<scoring_policy>`.
|
||||||
pub scoring_policy: ScoringPolicy,
|
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 {
|
impl Default for QtiOptions {
|
||||||
@@ -279,20 +364,44 @@ impl Default for QtiOptions {
|
|||||||
shuffle_options: false,
|
shuffle_options: false,
|
||||||
},
|
},
|
||||||
include_feedback: true,
|
include_feedback: true,
|
||||||
shuffle_in_canvas: false,
|
shuffle_in_canvas: true,
|
||||||
attempts: 1,
|
// 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,
|
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.
|
/// 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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Package {
|
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,
|
pub quiz_filename: String,
|
||||||
/// The quiz XML.
|
/// The quiz QTI XML.
|
||||||
pub quiz_xml: String,
|
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.
|
/// The manifest XML.
|
||||||
pub manifest_xml: String,
|
pub manifest_xml: String,
|
||||||
}
|
}
|
||||||
@@ -309,10 +418,12 @@ impl Package {
|
|||||||
/// Returns [`Error::Io`] on a write failure.
|
/// Returns [`Error::Io`] on a write failure.
|
||||||
pub fn write_zip(&self, path: &std::path::Path) -> Result<()> {
|
pub fn write_zip(&self, path: &std::path::Path) -> Result<()> {
|
||||||
let mut z = ZipBuilder::new();
|
let mut z = ZipBuilder::new();
|
||||||
// Canvas only recognizes the archive as QTI when the manifest sits at the
|
// Canvas only recognizes the archive as a course/quiz package when the
|
||||||
// root rather than inside a directory.
|
// 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("imsmanifest.xml", &self.manifest_xml);
|
||||||
z.add_text(&self.quiz_filename, &self.quiz_xml);
|
z.add_text(&self.quiz_filename, &self.quiz_xml);
|
||||||
|
z.add_text(&self.meta_filename, &self.meta_xml);
|
||||||
z.write_to(path)
|
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 problems = Vec::new();
|
||||||
let mut items = 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) {
|
for placement in select::layout(record, &opts.form) {
|
||||||
let entry = catalog.require(&placement.item)?;
|
let entry = catalog.require(&placement.item)?;
|
||||||
@@ -357,6 +473,9 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
|||||||
let points = placement
|
let points = placement
|
||||||
.points
|
.points
|
||||||
.unwrap_or_else(|| item.points(default_points));
|
.unwrap_or_else(|| item.points(default_points));
|
||||||
|
if !placement.bonus {
|
||||||
|
total_points += points;
|
||||||
|
}
|
||||||
items.push(build_item(
|
items.push(build_item(
|
||||||
&record.assessment.id,
|
&record.assessment.id,
|
||||||
&placement.item,
|
&placement.item,
|
||||||
@@ -370,18 +489,14 @@ pub fn build(catalog: &Catalog, record: &AssessmentFile, opts: &QtiOptions) -> R
|
|||||||
return Err(Error::Invalid(problems));
|
return Err(Error::Invalid(problems));
|
||||||
}
|
}
|
||||||
|
|
||||||
let metadata = vec![
|
// The QTI file carries only `cc_maxattempts`, matching what Canvas's own
|
||||||
metadata_field("cc_maxattempts", &opts.attempts.to_string()),
|
// export writes. Scoring policy, shuffle, and the results-visibility settings
|
||||||
metadata_field("cc_quiz_scoring_policy", opts.scoring_policy.as_str()),
|
// live in `assessment_meta.xml` instead; writing them here too would create a
|
||||||
metadata_field(
|
// second, potentially disagreeing source of truth.
|
||||||
"cc_shuffle_answers",
|
let metadata = vec![metadata_field(
|
||||||
if opts.shuffle_in_canvas {
|
"cc_maxattempts",
|
||||||
"true"
|
&max_attempts_field(opts.attempts),
|
||||||
} else {
|
)];
|
||||||
"false"
|
|
||||||
},
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
let title = if record.forms.len() > 1 {
|
let title = if record.forms.len() > 1 {
|
||||||
format!("{} (form {})", record.assessment.title, opts.form.id)
|
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)
|
.attr("xsi:schemaLocation", QTI_SCHEMA)
|
||||||
.child(assessment);
|
.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(
|
let manifest = build_manifest(
|
||||||
&quiz_filename,
|
&quiz_href,
|
||||||
|
&meta_href,
|
||||||
&assessment_id,
|
&assessment_id,
|
||||||
|
&meta_resource_id,
|
||||||
&title,
|
&title,
|
||||||
&record.assessment.id,
|
&record.assessment.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(Package {
|
Ok(Package {
|
||||||
quiz_filename,
|
quiz_filename: quiz_href,
|
||||||
quiz_xml: root.document(),
|
quiz_xml: root.document(),
|
||||||
|
meta_filename: meta_href,
|
||||||
|
meta_xml: meta.document(),
|
||||||
manifest_xml: manifest.document(),
|
manifest_xml: manifest.document(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -726,15 +857,29 @@ fn metadata_field(label: &str, entry: &str) -> Node {
|
|||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `quiz_filename` - the quiz XML file name.
|
/// * `quiz_href` - the in-archive path of the quiz XML file.
|
||||||
/// * `assessment_id` - the assessment identifier, reused as the resource id.
|
/// * `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.
|
/// * `title` - the human title.
|
||||||
/// * `salt` - salts the manifest identifier.
|
/// * `salt` - salts the manifest identifier.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// The manifest element.
|
/// 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(
|
let lom = Node::new("imsmd:lom").child(
|
||||||
Node::new("imsmd:general").child(
|
Node::new("imsmd:general").child(
|
||||||
Node::new("imsmd:title").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")
|
Node::new("manifest")
|
||||||
.attr("identifier", format!("man{}", qti_id(salt)))
|
.attr("identifier", format!("man{}", qti_id(salt)))
|
||||||
.attr("xmlns", IMSCP_NS)
|
.attr("xmlns", IMSCP_NS)
|
||||||
@@ -765,16 +926,112 @@ fn build_manifest(quiz_filename: &str, assessment_id: &str, title: &str, salt: &
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
Node::new("resources").child(
|
Node::new("resources")
|
||||||
Node::new("resource")
|
.child(quiz_resource)
|
||||||
.attr("identifier", assessment_id)
|
.child(meta_resource),
|
||||||
.attr("type", "imsqti_xmlv1p2")
|
|
||||||
.attr("href", quiz_filename)
|
|
||||||
.child(Node::new("file").attr("href", quiz_filename)),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// A deterministic QTI identifier: `g` followed by 32 hex characters.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
@@ -874,11 +1131,71 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn manifest_points_at_the_quiz_file() {
|
fn max_attempts_field_is_unlimited_not_negative_one() {
|
||||||
let m = build_manifest("exam-4.xml", "gabc", "Exam 4", "exam-4").render(0);
|
// The bug this guards against: writing the in-memory `-1` sentinel
|
||||||
assert!(m.contains("imsqti_xmlv1p2"));
|
// straight into the XML instead of translating it to the literal
|
||||||
assert!(m.contains("href=\"exam-4.xml\""));
|
// 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"));
|
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]
|
#[test]
|
||||||
@@ -959,6 +1276,28 @@ mod tests {
|
|||||||
assert_eq!(option_feedback(&no_hint, &opts), None);
|
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 {
|
fn catalog(tag: &str) -> Catalog {
|
||||||
let dir = std::env::temp_dir().join(format!("cb-qti-{tag}-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("cb-qti-{tag}-{}", std::process::id()));
|
||||||
let _ = std::fs::remove_dir_all(&dir);
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
@@ -1075,5 +1414,30 @@ items:
|
|||||||
// answer rides along as feedback.
|
// answer rides along as feedback.
|
||||||
assert!(pkg.quiz_xml.contains("\\(q_p = \\Delta H\\)"));
|
assert!(pkg.quiz_xml.contains("\\(q_p = \\Delta H\\)"));
|
||||||
assert!(pkg.quiz_xml.contains("H = U + PV"));
|
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