diff --git a/src/export/practice.rs b/src/export/practice.rs index b49f288..ed156ff 100644 --- a/src/export/practice.rs +++ b/src/export/practice.rs @@ -259,7 +259,11 @@ pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: &Options) -> Res /// The Quarto YAML front matter. fn front_matter(course: &CourseFile, record: &AssessmentFile, variant: Variant) -> String { - let title = format!("{}: {}", record.assessment.title, variant.title_word()); + let title = if matches!(variant, Variant::Solutions) { + format!("{}: {}", record.assessment.title, variant.title_word()) + } else { + record.assessment.title.clone() + }; let subtitle = format!("{} ยท {}", course.course.code, course.course.title); let mut out = String::from("---\n"); out.push_str(&format!("title: \"{}\"\n", yaml_quote(&title))); diff --git a/src/export/site.rs b/src/export/site.rs index 53639b6..a90375d 100644 --- a/src/export/site.rs +++ b/src/export/site.rs @@ -329,10 +329,7 @@ fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form )); } if let Some(explanation) = &solution.explanation { - out.push_str(&format!( - "

{}

\n", - inline_html(explanation) - )); + out.push_str(&explain_html(explanation)); } } @@ -377,7 +374,8 @@ fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form out } -/// An open-response fragment: the model answer, the rubric, accepted variants. +/// An open-response fragment: the model answer, the explanation, the rubric, and +/// the accepted variants. fn open_fragment(course: &CourseFile, item: &Item) -> Option { let solution = item.solution.as_ref().filter(|s| !s.is_empty())?; let mut out = String::new(); @@ -389,6 +387,10 @@ fn open_fragment(course: &CourseFile, item: &Item) -> Option { )); } + if let Some(explanation) = &solution.explanation { + out.push_str(&explain_html(explanation)); + } + if !solution.rubric.is_empty() { let caption = match solution.rubric_points() { Some(total) => { @@ -593,6 +595,18 @@ fn block_html(src: &str) -> String { .join("") } +/// Renders a solution explanation as one `

` per blank-line- +/// separated paragraph. A single-paragraph explanation emits exactly one such +/// paragraph, unchanged from before; a multi-paragraph one keeps its breaks, and +/// every paragraph carries the class `questions.css` already styles. +fn explain_html(src: &str) -> String { + src.split("\n\n") + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(|p| format!("

{}

\n", inline_html(p))) + .collect() +} + // --- the encrypted bundle ----- /// The encrypted solutions bundle, matching the `solutions.js` v1 format. @@ -805,6 +819,10 @@ items: learning_objectives: [lo-enthalpy] solution: model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$." + explanation: | + At constant pressure the pressure-volume work is folded into H, so the heat equals the change in H. + + That is why a calorimeter run at constant pressure reads the enthalpy change directly. rubric: - { description: "States $H = U + PV$.", points: 1 } - { description: "Reaches $q_p = \\Delta H$.", points: 1 } @@ -914,6 +932,31 @@ items: assert!(open.contains("Also accepted")); } + #[test] + fn an_open_fragment_shows_the_model_answer_and_the_explanation() { + // Both fields render, in that order, so an open-response solution reads as + // the answer followed by the reasoning, the same as a choice fragment. A + // multi-paragraph explanation keeps its breaks as separate paragraphs. + let cat = catalog("open-explain"); + let rec = record(); + let frags = solution_fragments(&cat, &rec, &form()).unwrap(); + let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1; + assert!(open.contains("
"), "model answer shown:\n{open}"); + assert_eq!( + open.matches("

").count(), + 2, + "each explanation paragraph is its own styled

:\n{open}" + ); + assert!(open.contains("folded into H"), "first paragraph present:\n{open}"); + assert!( + open.contains("reads the enthalpy change directly"), + "second paragraph present:\n{open}" + ); + let model_at = open.find("sol-model").unwrap(); + let explain_at = open.find("sol-explain").unwrap(); + assert!(model_at < explain_at, "model answer comes before the explanation"); + } + #[test] fn inline_html_keeps_math_verbatim_and_escapes_prose() { // A subscript inside math survives; angle brackets outside math are escaped. @@ -973,4 +1016,4 @@ items: assert!(assets[0].1.contains(".qsol")); assert!(assets[1].1.contains("AES-GCM")); } -} +} \ No newline at end of file