fix: improve canvas export

This commit is contained in:
2026-08-26 21:24:22 -04:00
parent 4cd33f1768
commit d38d89549c
2 changed files with 418 additions and 42 deletions
+14 -2
View File
@@ -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
View File
@@ -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="));
}
}