diff --git a/src/commands/export.rs b/src/commands/export.rs index c42cdfc..1a4f2e1 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -39,12 +39,24 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result { 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 diff --git a/src/export/qti.rs b/src/export/qti.rs index c8583f1..b4aeab5 100644 --- a/src/export/qti.rs +++ b/src/export/qti.rs @@ -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 ``. +/// +/// [`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 `` 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 `` 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 `` in the meta file. pub attempts: i64, - /// How repeated attempts are scored. + /// How repeated attempts are scored. Emitted as ``. pub scoring_policy: ScoringPolicy, + /// The Canvas quiz type. Emitted as ``. + 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 ``: 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 ``. + pub show_correct_answers: bool, + /// Whether Canvas shows one question per page. Emitted as + /// ``. + pub one_question_at_a_time: bool, + /// A time limit in minutes, or `None` for untimed. Emitted as ``. + pub time_limit_minutes: Option, + /// 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 `` and the + /// assignment's ``. + 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 `` 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 ``, 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. `` 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 `` 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 `` 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("")); + } + + #[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("keep_highest")); + assert!(m.contains("-1")); + assert!(m.contains("assignment")); + assert!(m.contains("true")); + assert!(m.contains("false")); + // Default is unpublished, so an import never goes live unreviewed. + assert!(m.contains("false")); + assert!(m.contains("unpublished")); + // 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(""), "{m}"); + assert!(!m.contains("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("always"), "{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("keep_highest") + ); + assert!( + pkg.meta_xml + .contains("-1") + ); + assert!(pkg.manifest_xml.contains(&pkg.meta_filename)); + assert!(pkg.manifest_xml.contains("