// SPDX-License-Identifier: Prosperity-3.0.0 // Copyright Scientific Computing Studio // Source: https://git.scient.ing/education/coursebank //! Writing reports for students and for yourself. //! //! Two audiences, two documents, and the difference between them is not tone but //! content. //! //! The **student report** answers "what should I do next?". It gives a score, a //! coarse position in the class, per-objective standing, and — the part that //! actually helps — for each missed question, the misconception that the specific //! distractor they chose was written to detect, plus where in the course to go //! back to. It never prints a correct answer, never names another student, and //! never reports a rank. Those omissions are deliberate: a report that reveals //! keys cannot be sent out before a makeup exam, and a report that gives a rank //! invites the student to read it as a verdict rather than as instructions. //! //! The **instructor report** answers "what should I fix?". Item statistics, the //! revision queue, distractor tables, reliability, blueprint coverage, and the //! class-level objectives that nobody met. That last section is the one that //! should change your teaching rather than any individual student's studying. //! //! Output is Markdown. It is readable as-is in a terminal or a text editor, it //! diffs cleanly, and [`to_html`] converts it for emailing or posting. The HTML //! converter handles exactly the Markdown this module emits — headings, tables, //! lists, bold, italic, code, blockquotes, rules — and nothing more; it is not a //! general Markdown implementation and does not pretend to be. use std::collections::BTreeMap; use crate::assessment::AssessmentFile; use crate::catalog::Catalog; use crate::classical::Analysis; use crate::course::CourseFile; use crate::date::Date; use crate::irt::Fit; use crate::students::{Cohort, Mastery, StudentSummary}; use crate::taxonomy::Level; /// What to include in a student report. #[derive(Debug, Clone)] pub struct StudentOptions { /// Whether to include the per-objective table. pub objectives: bool, /// Whether to include the per-level comparison to the class. pub levels: bool, /// Whether to include per-question guidance on missed items. pub missed: bool, /// Whether to show the class mean and the student's band. Turn this off for a /// course where any comparison is unwelcome. pub comparison: bool, /// Whether to include the IRT ability estimate. Off by default: it is not /// meaningful to most students and invites misreading. pub ability: bool, /// A closing note appended verbatim, e.g. office-hours information. pub closing: Option, } impl Default for StudentOptions { fn default() -> StudentOptions { StudentOptions { objectives: true, levels: true, missed: true, comparison: true, ability: false, closing: None, } } } /// Writes one student's report. /// /// # Arguments /// /// * `summary` - the student's summary. /// * `cohort` - the class context, for means. /// * `course` - the course, for titles. /// * `record` - the assessment record, for the title and date. /// * `opts` - what to include. /// /// # Returns /// /// A Markdown document. pub fn student( summary: &StudentSummary, cohort: &Cohort, course: &CourseFile, record: &AssessmentFile, opts: &StudentOptions, ) -> String { let mut out = String::new(); out.push_str(&format!( "# {} — {}\n\n", record.assessment.title, course.course.code )); out.push_str(&format!("**{}**\n\n", summary.display_name())); // ---------------------------------------------------------------- score out.push_str(&format!( "You scored **{:.1} of {:.1} points ({:.0}%)**", summary.points, summary.points_possible, summary.percent )); if summary.bonus_points > 0.0 { out.push_str(&format!( ", plus {:.1} bonus point{}", summary.bonus_points, if (summary.bonus_points - 1.0).abs() < 1e-9 { "" } else { "s" } )); } out.push_str(&format!( ", answering {} of {} questions correctly.\n\n", summary.correct, summary.n_items )); if opts.comparison { out.push_str(&format!( "The class averaged {:.0}%. Your score is in the {}.\n\n", cohort.mean_percent, summary.band )); } if opts.ability { if let (Some(theta), Some(se)) = (summary.theta, summary.theta_se) { out.push_str(&format!( "Adjusting for how difficult each question turned out to be, your estimated \ standing is {} (θ = {theta:+.2}, ± {:.2}). The margin is wide because a single \ exam is a small amount of evidence.\n\n", crate::irt::Ability { student_key: summary.student_key.clone(), theta, se, n_items: summary.n_items, } .band(), se * 1.96 )); } } // ----------------------------------------------------------- objectives if opts.objectives && !summary.objectives.is_empty() { out.push_str("## What this exam says about each learning objective\n\n"); out.push_str("| | Objective | You | Class | Items |\n|:--|:--|--:|--:|--:|\n"); for o in &summary.objectives { let you = format!("{:.0}%", o.rate * 100.0); out.push_str(&format!( "| {} | {} | {} | {:.0}% | {} |\n", o.status.symbol(), escape_pipes(&o.text), you, o.cohort_rate * 100.0, o.n_items )); } out.push('\n'); out.push_str("✓ meeting · ~ developing · ✗ not yet · ? too few questions to tell\n\n"); // The "too few questions" cases are an honest caveat about the exam, and // saying so protects the student from over-reading a single data point. let thin: Vec<&str> = summary .objectives .iter() .filter(|o| o.status == Mastery::NotEnoughEvidence) .map(|o| o.text.as_str()) .collect(); if !thin.is_empty() { out.push_str(&format!( "This exam had too few questions on {} to say anything reliable about {}. Treat \ those rows as information about the exam, not about you.\n\n", list(&thin), if thin.len() == 1 { "it" } else { "them" } )); } } // --------------------------------------------------------------- levels if opts.levels && summary.levels.len() > 1 { out.push_str("## Kinds of thinking\n\n"); out.push_str( "Questions on this exam asked for different kinds of thinking. Comparing your rate \ across them often shows more than the total score does.\n\n", ); out.push_str("| Kind of question | You | Class | |\n|:--|--:|--:|:--|\n"); for l in &summary.levels { out.push_str(&format!( "| {} — {} | {:.0}% | {:.0}% | {} |\n", l.level.name(), l.level.blurb(), l.rate * 100.0, l.cohort_rate * 100.0, bar(l.rate) )); } out.push('\n'); // The interesting pattern: fine on recall, falling apart on application. let recall: Vec = summary .levels .iter() .filter(|l| l.level.code() <= 2) .map(|l| l.rate) .collect(); let applied: Vec = summary .levels .iter() .filter(|l| l.level.code() >= 3) .map(|l| l.rate) .collect(); if !recall.is_empty() && !applied.is_empty() { let drop = average(&recall) - average(&applied); if drop > 0.2 { out.push_str( "You are recalling the material but losing ground when you have to use it. \ That usually means more practice working problems rather than more rereading \ — rereading feels productive and mostly rebuilds recognition.\n\n", ); } else if drop < -0.2 { out.push_str( "You reason well with the material when it is in front of you, but specific \ facts and terms are costing you points. That is the more tractable of the two \ problems: targeted memorization of the terms below will help.\n\n", ); } } } // ---------------------------------------------------------- what to do if !summary.focus.is_empty() { out.push_str("## Where to put your time\n\n"); out.push_str("In this order:\n\n"); for (i, id) in summary.focus.iter().take(4).enumerate() { let text = course.objective_text(id); out.push_str(&format!("{}. {}\n", i + 1, text)); } out.push('\n'); // Distinguish "you missed this" from "the class missed this", because the // second is going to be retaught and does not need solo review. let class_gaps: Vec<&str> = cohort .class_gaps .iter() .filter(|(id, _)| summary.focus.contains(id)) .map(|(id, _)| id.as_str()) .collect(); if !class_gaps.is_empty() { let texts: Vec = class_gaps .iter() .map(|id| course.objective_text(id)) .collect(); let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect(); out.push_str(&format!( "Most of the class also struggled with {}, so expect it to come back in class. \ Prioritize the other items above for solo review.\n\n", list(&refs) )); } } if !summary.strengths.is_empty() { let texts: Vec = summary .strengths .iter() .take(4) .map(|id| course.objective_text(id)) .collect(); let refs: Vec<&str> = texts.iter().map(|s| s.as_str()).collect(); out.push_str(&format!("You have clearly got {}.\n\n", list(&refs))); } // --------------------------------------------------------- missed items if opts.missed && !summary.missed.is_empty() { out.push_str("## Question by question\n\n"); out.push_str( "For each question you missed, here is what the answer you chose usually indicates, \ and where to go back to. Correct answers are not listed here.\n\n", ); for m in &summary.missed { let partial = if m.credit > 0.0 { format!(" (partial credit: {:.0}%)", m.credit * 100.0) } else { String::new() }; out.push_str(&format!("**Question {}**{partial}\n\n", m.number)); if let Some(text) = &m.feedback { out.push_str(&format!("{text}\n\n")); } else if let Some(misconception) = &m.misconception { out.push_str(&format!( "The option you chose is the one students pick when {misconception}\n\n" )); } if !m.study.is_empty() { out.push_str(&format!("Review: {}\n\n", m.study.join("; "))); } } } if let Some(closing) = &opts.closing { out.push_str("---\n\n"); out.push_str(closing); out.push_str("\n\n"); } out.push_str(&format!( "---\n\n*Generated {} for {}. Percentages on individual objectives come from a handful of \ questions each and carry real uncertainty; read them as directions, not measurements.*\n", Date::today(), summary.display_name() )); out } /// Writes the instructor's report on one administration. /// /// # Arguments /// /// * `analysis` - classical item analysis. /// * `cohort` - per-student summaries and class rates. /// * `catalog` - the loaded course. /// * `record` - the assessment record. /// * `fit` - an optional IRT fit. /// /// # Returns /// /// A Markdown document. pub fn cohort( analysis: &Analysis, cohort: &Cohort, catalog: &Catalog, record: &AssessmentFile, fit: Option<&Fit>, ) -> String { let mut out = String::new(); let course = &catalog.course; out.push_str(&format!( "# {} — item analysis\n\n{} · {} · {}\n\n", record.assessment.title, course.course.code, record .assessment .term .clone() .unwrap_or_else(|| course.course.term.clone()), record .assessment .date .map(|d| d.to_string()) .unwrap_or_else(|| "date not recorded".into()) )); // ------------------------------------------------------------- summary let r = &analysis.reliability; out.push_str("## Summary\n\n"); out.push_str(&format!( "- {} examinees, {} scored items\n- Mean score {:.1} of {} ({:.0}%), SD {:.2}\n- \ Mean p-value {:.2}, mean point-biserial {}\n", r.n_students, r.n_items, r.mean, r.n_items, if r.n_items > 0 { 100.0 * r.mean / r.n_items as f64 } else { 0.0 }, r.sd, r.mean_p, r.mean_point_biserial .map(|v| format!("{v:+.2}")) .unwrap_or_else(|| "n/a".into()) )); out.push('\n'); out.push_str(&r.interpretation()); out.push_str("\n\n"); if let Some(f) = fit { let peak = f.peak_information(); out.push_str(&format!( "The IRT fit ({} model, {} iterations{}) measures most precisely around θ = {peak:+.1}", match f.items.first().map(|i| i.model) { Some(m) => m.as_str(), None => "?", }, f.iterations, if f.converged { "" } else { ", did not converge" } )); match f.standard_error(peak) { Some(se) => out.push_str(&format!( ", where the standard error is {se:.2} logits.\n\n" )), None => out.push_str(".\n\n"), } } for w in &analysis.warnings { out.push_str(&format!("> {w}\n\n")); } // ------------------------------------------------------- revise queue let queue = analysis.revise_queue(); out.push_str("## What to revise\n\n"); if queue.is_empty() { out.push_str( "Nothing was flagged. Unusual, and worth a skeptical glance at whether the \ key and the record actually matched the exam.\n\n", ); } else { let blocking = queue.iter().filter(|i| i.needs_revision()).count(); out.push_str(&format!( "{} item(s) flagged; {blocking} need attention before being used again.\n\n", queue.len() )); for item in queue { let label = item .item_ref .clone() .unwrap_or_else(|| format!("question {}", item.number)); out.push_str(&format!( "### Q{} — {}{}\n\n", item.number, label, if item.needs_revision() { " ⚠" } else { "" } )); out.push_str(&format!( "p = {:.2} · r = {} · flags: {}\n\n", item.p_value, item.point_biserial .map(|v| format!("{v:+.2}")) .unwrap_or_else(|| "n/a".into()), item.flags .iter() .map(|f| f.as_str()) .collect::>() .join(", ") )); for note in &item.notes { out.push_str(&format!("- {note}\n")); } out.push('\n'); // The distractor table is where a poorly worded item shows itself. if item.options.len() > 1 { out.push_str( "| Option | Chose | r | Upper | Lower | |\n|:--|--:|--:|--:|--:|:--|\n", ); for o in item.options.values() { out.push_str(&format!( "| {} | {:.0}% | {} | {} | {} | {} |\n", o.letter, o.rate * 100.0, o.point_biserial .map(|v| format!("{v:+.2}")) .unwrap_or_else(|| "n/a".into()), o.upper_rate .map(|v| format!("{:.0}%", v * 100.0)) .unwrap_or_else(|| "-".into()), o.lower_rate .map(|v| format!("{:.0}%", v * 100.0)) .unwrap_or_else(|| "-".into()), if o.is_key { "**key**" } else { "" } )); } out.push('\n'); } } } // ---------------------------------------------------------- item table out.push_str("## Every item\n\n"); out.push_str( "| Q | Item | Lv | p | r | D | Blank | Flags |\n|--:|:--|--:|--:|--:|--:|--:|:--|\n", ); for item in &analysis.items { let level = record .placement(item.number) .and_then(|p| p.level) .map(|l| l.code().to_string()) .unwrap_or_else(|| "-".into()); out.push_str(&format!( "| {} | {} | {} | {:.2} | {} | {} | {:.0}% | {} |\n", item.number, item.item_ref.clone().unwrap_or_default(), level, item.p_value, item.point_biserial .map(|v| format!("{v:+.2}")) .unwrap_or_else(|| "n/a".into()), item.discrimination_index .map(|v| format!("{v:+.2}")) .unwrap_or_else(|| "-".into()), item.blank_rate * 100.0, item.flags .iter() .map(|f| f.as_str()) .collect::>() .join(" ") )); } out.push('\n'); if let Some(f) = fit { out.push_str("## IRT parameters\n\n"); out.push_str("| Q | a | b | SE(a) | SE(b) | n | Notes |\n|--:|--:|--:|--:|--:|--:|:--|\n"); for item in &f.items { out.push_str(&format!( "| {} | {:.2} | {:+.2} | {} | {} | {} | {} |\n", item.number, item.a, item.b, item.se_a .map(|v| format!("{v:.2}")) .unwrap_or_else(|| "-".into()), item.se_b .map(|v| format!("{v:.2}")) .unwrap_or_else(|| "-".into()), item.n, item.notes.join(" ") )); } out.push('\n'); for w in &f.warnings { out.push_str(&format!("> {w}\n\n")); } } // -------------------------------------------------------- class gaps out.push_str("## Objectives the class did not meet\n\n"); if cohort.class_gaps.is_empty() { out.push_str("Every assessed objective cleared the mastery threshold.\n\n"); } else { out.push_str(&format!( "Below the {:.0}% threshold. These are the candidates for reteaching rather than for \ individual review.\n\n", course.policy.mastery_threshold * 100.0 )); out.push_str("| Objective | Class rate |\n|:--|--:|\n"); for (id, rate) in &cohort.class_gaps { out.push_str(&format!( "| {} | {:.0}% |\n", escape_pipes(&course.objective_text(id)), rate * 100.0 )); } out.push('\n'); } // ------------------------------------------------------ level coverage out.push_str("## Coverage and class performance by level\n\n"); let counts = record.level_counts(); out.push_str("| Level | Items | Class rate |\n|:--|--:|--:|\n"); for level in Level::ALL { let n = counts.get(&level).copied().unwrap_or(0); if n == 0 { continue; } out.push_str(&format!( "| {} {} | {} | {:.0}% |\n", level.code(), level.name(), n, cohort.level_rates.get(&level).copied().unwrap_or(0.0) * 100.0 )); } out.push('\n'); if let Some(bp) = &record.blueprint { let drift = crate::select::check_blueprint(record); if !drift.is_empty() { out.push_str("Blueprint drift:\n\n"); for d in &drift { out.push_str(&format!("- {d}\n")); } out.push('\n'); } let _ = bp; } // -------------------------------------------------------- archetypes if !cohort.archetypes.is_empty() { out.push_str("## Patterns across students\n\n"); out.push_str( "Descriptive grouping by performance profile across levels, not a diagnosis. Useful \ for deciding whether a review session should target one gap or several.\n\n", ); for a in &cohort.archetypes { let means: Vec = a .level_means .iter() .map(|(l, v)| format!("L{}: {:.0}%", l.code(), v * 100.0)) .collect(); out.push_str(&format!( "- **{}** — {} student(s); {}\n", a.label, a.members.len(), means.join(", ") )); } out.push('\n'); } out.push_str(&format!("---\n\n*Generated {}.*\n", Date::today())); out } /// Writes a one-line-per-student roster of scores. /// /// # Arguments /// /// * `cohort` - the class. /// /// # Returns /// /// A Markdown table. pub fn roster(cohort: &Cohort) -> String { let mut out = String::from("| Student | Points | % | Correct | Focus |\n|:--|--:|--:|--:|:--|\n"); let mut sorted: Vec<&StudentSummary> = cohort.students.iter().collect(); sorted.sort_by(|a, b| { b.percent .partial_cmp(&a.percent) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| a.student_key.cmp(&b.student_key)) }); for s in sorted { out.push_str(&format!( "| {} | {:.1} | {:.0}% | {}/{} | {} |\n", s.display_name(), s.points, s.percent, s.correct, s.n_items, s.focus .iter() .take(2) .cloned() .collect::>() .join(", ") )); } out } /// A small unicode bar for a rate in `0.0..=1.0`. /// /// # Arguments /// /// * `rate` - the value. /// /// # Returns /// /// A ten-cell bar. fn bar(rate: f64) -> String { let filled = (rate.clamp(0.0, 1.0) * 10.0).round() as usize; format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled)) } /// The mean of a slice, zero when empty. fn average(v: &[f64]) -> f64 { if v.is_empty() { 0.0 } else { v.iter().sum::() / v.len() as f64 } } /// Joins items into an English list. /// /// # Arguments /// /// * `items` - the items. /// /// # Returns /// /// `"a"`, `"a and b"`, or `"a, b, and c"`. fn list(items: &[&str]) -> String { match items.len() { 0 => String::new(), 1 => items[0].to_string(), 2 => format!("{} and {}", items[0], items[1]), _ => { let head = items[..items.len() - 1].join(", "); format!("{head}, and {}", items[items.len() - 1]) } } } /// Escapes pipes so objective text cannot break a Markdown table. fn escape_pipes(s: &str) -> String { s.replace('|', "\\|") } /// Converts the Markdown this module emits into a standalone HTML document. /// /// Handles headings, paragraphs, unordered and ordered lists, tables, /// blockquotes, horizontal rules, and inline bold, italic, and code. This is not a /// general Markdown implementation: it covers the constructs the generators above /// produce, and unrecognized syntax passes through as escaped text rather than /// being silently mangled. /// /// # Arguments /// /// * `markdown` - the document. /// * `title` - the HTML title. /// /// # Returns /// /// A complete HTML document with embedded styles, so it can be emailed or opened /// with no other files. pub fn to_html(markdown: &str, title: &str) -> String { let mut body = String::new(); let mut lines = markdown.lines().peekable(); let mut list_kind: Option<&str> = None; // Closes an open list, if any. fn close_list(body: &mut String, list_kind: &mut Option<&str>) { if let Some(tag) = list_kind.take() { body.push_str(&format!("\n")); } } while let Some(line) = lines.next() { let trimmed = line.trim(); if trimmed.is_empty() { close_list(&mut body, &mut list_kind); continue; } if trimmed.starts_with("---") && trimmed.chars().all(|c| c == '-') { close_list(&mut body, &mut list_kind); body.push_str("
\n"); continue; } if let Some(rest) = trimmed.strip_prefix("> ") { close_list(&mut body, &mut list_kind); body.push_str(&format!("
{}
\n", inline(rest))); continue; } // Headings. let hashes = trimmed.chars().take_while(|c| *c == '#').count(); if hashes > 0 && hashes <= 6 && trimmed.chars().nth(hashes) == Some(' ') { close_list(&mut body, &mut list_kind); let text = trimmed[hashes + 1..].trim(); body.push_str(&format!("{}\n", inline(text))); continue; } // Tables: a header row followed by an alignment row. if trimmed.starts_with('|') { let is_separator = |s: &str| { s.trim().starts_with('|') && s.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t')) && s.contains('-') }; if lines.peek().map(|n| is_separator(n)).unwrap_or(false) { close_list(&mut body, &mut list_kind); lines.next(); body.push_str("\n"); for cell in split_row(trimmed) { body.push_str(&format!("", inline(&cell))); } body.push_str("\n\n"); while let Some(next) = lines.peek() { if !next.trim().starts_with('|') { break; } // `peek` just succeeded, so `next` cannot be `None`; a // `let else` says that without a panic in the path. let Some(row) = lines.next() else { break }; body.push_str(""); for cell in split_row(row.trim()) { body.push_str(&format!("", inline(&cell))); } body.push_str("\n"); } body.push_str("\n
{}
{}
\n"); continue; } } // Lists. if let Some(rest) = trimmed.strip_prefix("- ") { if list_kind != Some("ul") { close_list(&mut body, &mut list_kind); body.push_str("
    \n"); list_kind = Some("ul"); } body.push_str(&format!("
  • {}
  • \n", inline(rest))); continue; } if let Some((prefix, rest)) = trimmed.split_once(". ") { if !prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_digit()) { if list_kind != Some("ol") { close_list(&mut body, &mut list_kind); body.push_str("
      \n"); list_kind = Some("ol"); } body.push_str(&format!("
    1. {}
    2. \n", inline(rest))); continue; } } close_list(&mut body, &mut list_kind); body.push_str(&format!("

      {}

      \n", inline(trimmed))); } close_list(&mut body, &mut list_kind); format!( "\n\n\n\n\ \n\ {}\n\n\n\n{}\n\n", crate::markup::escape_html(title), STYLE, body ) } /// Splits a Markdown table row into cells. fn split_row(row: &str) -> Vec { let inner = row.trim().trim_start_matches('|').trim_end_matches('|'); inner .split('|') .map(|c| c.trim().replace("\\|", "|")) .collect() } /// Converts inline Markdown to HTML. /// /// Escapes first, then applies emphasis, so text containing angle brackets cannot /// become markup. fn inline(s: &str) -> String { let escaped = crate::markup::escape_html(s); let mut out = escaped; out = wrap(&out, "**", "", ""); out = wrap(&out, "`", "", ""); out = wrap(&out, "*", "", ""); out } /// Replaces paired delimiters, leaving unpaired ones literal. fn wrap(s: &str, delim: &str, open: &str, close: &str) -> String { let mut out = String::with_capacity(s.len()); let mut rest = s; loop { let Some(i) = rest.find(delim) else { out.push_str(rest); return out; }; let after = &rest[i + delim.len()..]; let Some(j) = after.find(delim) else { out.push_str(rest); return out; }; if j == 0 { out.push_str(&rest[..i + delim.len()]); rest = after; continue; } out.push_str(&rest[..i]); out.push_str(open); out.push_str(&after[..j]); out.push_str(close); rest = &after[j + delim.len()..]; } } /// Embedded stylesheet for HTML reports. const STYLE: &str = "\ :root { color-scheme: light dark; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 46rem; margin: 2.5rem auto; padding: 0 1.25rem; line-height: 1.55; color: #1a1a1a; background: #fff; } h1 { font-size: 1.6rem; margin-bottom: 0.2rem; } h2 { font-size: 1.15rem; margin-top: 2rem; border-bottom: 1px solid #e5e5e5; padding-bottom: 0.3rem; } h3 { font-size: 1rem; margin-top: 1.5rem; } table { border-collapse: collapse; width: 100%; margin: 1rem 0; font-size: 0.92rem; } th, td { border-bottom: 1px solid #e5e5e5; padding: 0.4rem 0.6rem; text-align: left; } th { background: #fafafa; font-weight: 600; } td:nth-child(n+3), th:nth-child(n+3) { text-align: right; } code { background: #f5f5f5; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.9em; } blockquote { border-left: 3px solid #d0d0d0; margin: 1rem 0; padding: 0.3rem 0 0.3rem 1rem; color: #555; font-size: 0.95rem; } hr { border: none; border-top: 1px solid #e5e5e5; margin: 2rem 0; } em { color: #555; } ul, ol { padding-left: 1.4rem; } @media (prefers-color-scheme: dark) { body { color: #e8e8e8; background: #1a1a1a; } th { background: #262626; } th, td { border-bottom-color: #333; } code { background: #2a2a2a; } h2 { border-bottom-color: #333; } blockquote { border-left-color: #444; color: #aaa; } } "; /// Writes every student's report to a directory. /// /// # Arguments /// /// * `dir` - the destination directory. /// * `cohort` - the class. /// * `course` - the course. /// * `record` - the assessment record. /// * `opts` - report options. /// * `html` - whether to write HTML alongside Markdown. /// /// # Returns /// /// The paths written. /// /// # Errors /// /// Returns [`crate::error::Error::Io`] on a write failure. pub fn write_all_students( dir: &std::path::Path, cohort: &Cohort, course: &CourseFile, record: &AssessmentFile, opts: &StudentOptions, html: bool, ) -> crate::error::Result> { std::fs::create_dir_all(dir).map_err(|e| crate::error::Error::io(dir, e))?; let mut written = Vec::new(); for s in &cohort.students { let stem = crate::store::sanitize(&s.student_key); let markdown = student(s, cohort, course, record, opts); let md_path = dir.join(format!("{stem}.md")); crate::yaml::write_text(&md_path, &markdown)?; written.push(md_path); if html { let title = format!("{} — {}", record.assessment.title, s.display_name()); let html_path = dir.join(format!("{stem}.html")); crate::yaml::write_text(&html_path, &to_html(&markdown, &title))?; written.push(html_path); } } Ok(written) } /// Per-objective class rates as a compact table, for pasting into a syllabus /// review or a curriculum committee document. /// /// # Arguments /// /// * `cohort` - the class. /// * `course` - the course, for objective text. /// /// # Returns /// /// A Markdown table. pub fn objective_summary(cohort: &Cohort, course: &CourseFile) -> String { let mut out = String::from("| Objective | Class rate |\n|:--|--:|\n"); let mut rows: Vec<(&String, &f64)> = cohort.objective_rates.iter().collect(); rows.sort_by(|a, b| { a.1.partial_cmp(b.1) .unwrap_or(std::cmp::Ordering::Equal) .then_with(|| a.0.cmp(b.0)) }); for (id, rate) in rows { out.push_str(&format!( "| {} | {:.0}% |\n", escape_pipes(&course.objective_text(id)), rate * 100.0 )); } out } /// Counts flags across an analysis, for a headline figure. /// /// # Arguments /// /// * `analysis` - the analysis. /// /// # Returns /// /// How many items carry each flag. pub fn flag_counts(analysis: &Analysis) -> BTreeMap<&'static str, usize> { let mut out = BTreeMap::new(); for item in &analysis.items { for flag in &item.flags { *out.entry(flag.as_str()).or_insert(0) += 1; } } out } #[cfg(test)] mod tests { use super::*; #[test] fn bars_are_ten_cells_wide() { assert_eq!(bar(0.0).chars().count(), 10); assert_eq!(bar(1.0).chars().count(), 10); assert_eq!(bar(0.5).chars().count(), 10); assert!(bar(1.0).starts_with('█')); assert!(bar(0.0).starts_with('░')); // Out-of-range input must not panic or overflow. assert_eq!(bar(2.0).chars().count(), 10); assert_eq!(bar(-1.0).chars().count(), 10); } #[test] fn english_lists_read_correctly() { assert_eq!(list(&[]), ""); assert_eq!(list(&["a"]), "a"); assert_eq!(list(&["a", "b"]), "a and b"); assert_eq!(list(&["a", "b", "c"]), "a, b, and c"); } #[test] fn pipes_in_objective_text_do_not_break_tables() { assert_eq!(escape_pipes("a | b"), "a \\| b"); } #[test] fn html_conversion_handles_headings_and_paragraphs() { let html = to_html("# Title\n\nSome text.\n", "T"); assert!(html.contains("

      Title

      ")); assert!(html.contains("

      Some text.

      ")); assert!(html.starts_with("")); assert!(html.contains("T")); } #[test] fn html_conversion_builds_tables() { let md = "| A | B |\n|:--|--:|\n| 1 | 2 |\n"; let html = to_html(md, "T"); assert!(html.contains("")); assert!(html.contains("")); assert!(html.contains("")); assert!(html.contains("")); } #[test] fn html_conversion_handles_both_list_kinds() { let html = to_html("- one\n- two\n\n1. first\n2. second\n", "T"); assert!(html.contains("
        ")); assert!(html.contains("
      • one
      • ")); assert!(html.contains("
          ")); assert!(html.contains("
        1. first
        2. ")); // Lists must be closed, not left dangling. assert_eq!(html.matches("
            ").count(), html.matches("
          ").count()); assert_eq!(html.matches("
            ").count(), html.matches("
          ").count()); } #[test] fn html_conversion_escapes_before_emphasis() { let html = to_html("**bold** and \n", "T"); assert!(html.contains("bold")); assert!(!html.contains("
      A2