diff --git a/.gitignore b/.gitignore index aa35b9b..98491f9 100644 --- a/.gitignore +++ b/.gitignore @@ -192,3 +192,6 @@ src/crates/scriptmark/tests/fixtures/**/.DS_Store !examples/bundles/*/submissions/ !examples/bundles/*/submissions/** examples/bundles/**/__pycache__/ +# OSS-148: a bundle's roster, and the grade sheets it is expected to export. +!examples/bundles/*/*.csv +!examples/bundles/*/expected/*.csv diff --git a/Cargo.toml b/Cargo.toml index 9ca5f12..0ed1bab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,10 @@ regex = "1" # Archive zip = { version = "8", default-features = false, features = ["deflate"] } +# Spreadsheets +rust_xlsxwriter = "0.99" +calamine = "0.36" + # System libc = "0.2" diff --git a/README.md b/README.md index 9f062f1..2afca99 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,9 @@ scriptmark grade submissions/ -t tests/ --force # Read the latest revision, or any other with --revision N scriptmark summarize output/results.json --revision 1 scriptmark export output/results.json -o grades.csv +# The same grades as a workbook: 学号 kept as text, scores as numbers, and sheets for the +# items, the cases behind each grade and the record and revision they came from +scriptmark export output/results.json -o grades.xlsx scriptmark db save output/results.json --revision 1 --db grades.db # Preview student/file/function matching; edit assignment.toml to resolve candidates @@ -371,6 +374,7 @@ See [the scoring contract](https://github.com/Acture/obsidian-vault/blob/project - **Parallel** -- tokio orchestrator, grades 80+ students in seconds - **Parametrize + oracle** -- random inputs with teacher reference implementations - **Per-item scoring** -- declared points and aggregation; zero and withheld kept apart in every export +- **Grade sheets** -- one row per student as CSV or XLSX from one table, naming the assignment, revision and evidence they came from - **Canvas LMS** -- roster pull, grades push - **Similarity detection** -- style + structural code comparison - **TUI + HTML reports** -- interactive browser and standalone dashboards diff --git a/examples/bundles/grade_sheet/assignment.toml b/examples/bundles/grade_sheet/assignment.toml new file mode 100644 index 0000000..265d80e --- /dev/null +++ b/examples/bundles/grade_sheet/assignment.toml @@ -0,0 +1,28 @@ +# A class graded once and exported as a grade sheet, then rescored and exported again: +# +# scriptmark grade submissions -t tests -r roster.csv -o out/results.json +# scriptmark export out/results.json -o out/grades.csv +# scriptmark export out/results.json -o out/grades.xlsx +# scriptmark rescore out/results.json --assignment regrade.toml +# scriptmark export out/results.json --revision 2 -o out/grades.xlsx +# +# One row per student, in 学号 order. 0012301 is right; 0012302 earns a third of `parity`; +# 0012303 hands in wrong answers, a real 0; 0012304 hands in nothing, withheld; 0012305's +# `mean` raises; and 0012399 is on no roster, withheld until someone reviews it. Under +# regrade.toml, a missing submission is a 0, which the sheet marks `not_submitted`. +# expected/ holds both CSVs as this build writes them; the evidence column is the only +# part that differs on another machine. +[assignment] +name = "lab3 统计" + +[[items]] +id = "mean" +title = "平均值" +points = 6 +aggregation = "proportional" + +[[items]] +id = "parity" +title = "奇偶判断" +points = 1 +aggregation = "proportional" diff --git a/examples/bundles/grade_sheet/expected/grades-rescored.csv b/examples/bundles/grade_sheet/expected/grades-rescored.csv new file mode 100644 index 0000000..95c68e3 --- /dev/null +++ b/examples/bundles/grade_sheet/expected/grades-rescored.csv @@ -0,0 +1,7 @@ +student_id,student_name,canvas_user_id,state,reason,score,max,raw_grade,final_grade,mean_score,mean_state,mean_reason,parity_score,parity_state,parity_reason,assignment,revision,evidence +0012301,张三,,graded,,7,7,100,100,6,graded,,1,graded,,lab3 统计,2,36a938ed4d85 +0012302,李四,,graded,,6.3333,7,90.48,90.48,6,graded,,0.3333,graded,,lab3 统计,2,36a938ed4d85 +0012303,王五,,graded,,0,7,0,0,0,graded,,0,graded,,lab3 统计,2,36a938ed4d85 +0012304,赵六,,graded,not_submitted,0,7,0,0,0,graded,not_submitted,0,graded,not_submitted,lab3 统计,2,36a938ed4d85 +0012305,钱七,,graded,,1,7,14.29,14.29,0,graded,,1,graded,,lab3 统计,2,36a938ed4d85 +local:0012399,,,withheld,pending_review,,7,,,,,,,,,lab3 统计,2,36a938ed4d85 diff --git a/examples/bundles/grade_sheet/expected/grades.csv b/examples/bundles/grade_sheet/expected/grades.csv new file mode 100644 index 0000000..1402b07 --- /dev/null +++ b/examples/bundles/grade_sheet/expected/grades.csv @@ -0,0 +1,7 @@ +student_id,student_name,canvas_user_id,state,reason,score,max,raw_grade,final_grade,mean_score,mean_state,mean_reason,parity_score,parity_state,parity_reason,assignment,revision,evidence +0012301,张三,,graded,,7,7,100,100,6,graded,,1,graded,,lab3 统计,1,36a938ed4d85 +0012302,李四,,graded,,6.3333,7,90.48,90.48,6,graded,,0.3333,graded,,lab3 统计,1,36a938ed4d85 +0012303,王五,,graded,,0,7,0,0,0,graded,,0,graded,,lab3 统计,1,36a938ed4d85 +0012304,赵六,,withheld,not_submitted,,7,,,,,,,,,lab3 统计,1,36a938ed4d85 +0012305,钱七,,graded,,1,7,14.29,14.29,0,graded,,1,graded,,lab3 统计,1,36a938ed4d85 +local:0012399,,,withheld,pending_review,,7,,,,,,,,,lab3 统计,1,36a938ed4d85 diff --git a/examples/bundles/grade_sheet/regrade.toml b/examples/bundles/grade_sheet/regrade.toml new file mode 100644 index 0000000..bdfd973 --- /dev/null +++ b/examples/bundles/grade_sheet/regrade.toml @@ -0,0 +1,18 @@ +# The same items; a roster student who hands in nothing now gets 0, with its reason. +[assignment] +name = "lab3 统计" + +[grading] +missing = "zero" + +[[items]] +id = "mean" +title = "平均值" +points = 6 +aggregation = "proportional" + +[[items]] +id = "parity" +title = "奇偶判断" +points = 1 +aggregation = "proportional" diff --git a/examples/bundles/grade_sheet/roster.csv b/examples/bundles/grade_sheet/roster.csv new file mode 100644 index 0000000..9f22a59 --- /dev/null +++ b/examples/bundles/grade_sheet/roster.csv @@ -0,0 +1,6 @@ +name,class,student_id +张三,1班,0012301 +李四,1班,0012302 +王五,2班,0012303 +赵六,2班,0012304 +钱七,2班,0012305 diff --git a/examples/bundles/grade_sheet/submissions/0012301_lab.py b/examples/bundles/grade_sheet/submissions/0012301_lab.py new file mode 100644 index 0000000..b3248be --- /dev/null +++ b/examples/bundles/grade_sheet/submissions/0012301_lab.py @@ -0,0 +1,6 @@ +def mean(xs): + return sum(xs) / len(xs) + + +def is_even(n): + return n % 2 == 0 diff --git a/examples/bundles/grade_sheet/submissions/0012302_lab.py b/examples/bundles/grade_sheet/submissions/0012302_lab.py new file mode 100644 index 0000000..0c93d0d --- /dev/null +++ b/examples/bundles/grade_sheet/submissions/0012302_lab.py @@ -0,0 +1,6 @@ +def mean(xs): + return sum(xs) / len(xs) + + +def is_even(n): + return n > 1 diff --git a/examples/bundles/grade_sheet/submissions/0012303_lab.py b/examples/bundles/grade_sheet/submissions/0012303_lab.py new file mode 100644 index 0000000..bd9a58b --- /dev/null +++ b/examples/bundles/grade_sheet/submissions/0012303_lab.py @@ -0,0 +1,6 @@ +def mean(xs): + return 0 + + +def is_even(n): + return None diff --git a/examples/bundles/grade_sheet/submissions/0012305_lab.py b/examples/bundles/grade_sheet/submissions/0012305_lab.py new file mode 100644 index 0000000..6b26f45 --- /dev/null +++ b/examples/bundles/grade_sheet/submissions/0012305_lab.py @@ -0,0 +1,6 @@ +def mean(xs): + raise ValueError("\x1b[31m没有实现\x1b[0m") + + +def is_even(n): + return n % 2 == 0 diff --git a/examples/bundles/grade_sheet/submissions/0012399_lab.py b/examples/bundles/grade_sheet/submissions/0012399_lab.py new file mode 100644 index 0000000..b3248be --- /dev/null +++ b/examples/bundles/grade_sheet/submissions/0012399_lab.py @@ -0,0 +1,6 @@ +def mean(xs): + return sum(xs) / len(xs) + + +def is_even(n): + return n % 2 == 0 diff --git a/examples/bundles/grade_sheet/tests/test_mean.toml b/examples/bundles/grade_sheet/tests/test_mean.toml new file mode 100644 index 0000000..5f30ff6 --- /dev/null +++ b/examples/bundles/grade_sheet/tests/test_mean.toml @@ -0,0 +1,17 @@ +# 平均值: the mean of a list. + +[meta] +name = "mean" +file = "lab.py" +function = "mean" +language = "python" + +[[cases]] +name = "整数" +args = [[1, 2, 3]] +expect = 2.0 + +[[cases]] +name = "小数" +args = [[0.5, 1.5]] +expect = 1.0 diff --git a/examples/bundles/grade_sheet/tests/test_parity.toml b/examples/bundles/grade_sheet/tests/test_parity.toml new file mode 100644 index 0000000..d31428f --- /dev/null +++ b/examples/bundles/grade_sheet/tests/test_parity.toml @@ -0,0 +1,22 @@ +# 奇偶判断: three cases on one point, so passing one is a third of a point. + +[meta] +name = "parity" +file = "lab.py" +function = "is_even" +language = "python" + +[[cases]] +name = "二" +args = [2] +expect = true + +[[cases]] +name = "三" +args = [3] +expect = false + +[[cases]] +name = "零" +args = [0] +expect = true diff --git a/notes b/notes index 31e2505..08d9bbf 160000 --- a/notes +++ b/notes @@ -1 +1 @@ -Subproject commit 31e250583d579fc6f987cd789b0ca4349e82f93a +Subproject commit 08d9bbf6db8bda52b8560541b810085c2aeb5ec8 diff --git a/src/crates/scriptmark/Cargo.toml b/src/crates/scriptmark/Cargo.toml index 6925fa5..e32951e 100644 --- a/src/crates/scriptmark/Cargo.toml +++ b/src/crates/scriptmark/Cargo.toml @@ -30,6 +30,7 @@ thiserror = { workspace = true } anyhow = { workspace = true } regex = { workspace = true } zip = { workspace = true } +rust_xlsxwriter = { workspace = true } libc = { workspace = true } rand = { workspace = true } rand_chacha = { workspace = true } @@ -47,4 +48,5 @@ tempfile = "3" sha2 = "0.10" [dev-dependencies] +calamine = { workspace = true } wiremock = "0.6.5" diff --git a/src/crates/scriptmark/src/export.rs b/src/crates/scriptmark/src/export.rs index bf296a0..61fc448 100644 --- a/src/crates/scriptmark/src/export.rs +++ b/src/crates/scriptmark/src/export.rs @@ -1,13 +1,22 @@ -//! Grades leaving ScriptMark: a CSV a teacher can read, and the set pushed to Canvas. +//! Grades leaving ScriptMark: grade sheets a teacher can read, and the set pushed to Canvas. //! Both go by a grade's state, never by its number — a withheld grade is an empty cell and //! is never pushed; a real zero is `0` and is. +//! +//! A sheet is built once as a [`Table`] of typed cells and only then written, as CSV or as +//! XLSX, so the two cannot disagree: identifiers and words are text, so a 学号 keeps its +//! leading zeros, and scores are numbers rounded as the CSV prints them. use std::collections::BTreeMap; use std::io::Write; +use std::path::Path; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; +use rust_xlsxwriter::{Format as Style, FormatBorder, IgnoreError, Workbook}; +use serde_json::Value; +use crate::grading::{self, round_half_away}; use crate::models::{GradeOutcome, GradingItem, ItemOutcome, Reason, StudentReport}; +use crate::record::{Record, Source}; /// A snake_case word for a serialisable value, as the JSON results write it. pub fn word(value: &T) -> String { @@ -55,27 +64,171 @@ pub fn grades_to_push(reports: &[StudentReport]) -> Result { Ok(set) } -/// One row per student: the grade, why there is none or why it is a policy zero, and each -/// item's score and state. Withheld cells are empty; a zero is `0`. -pub fn write_grades_csv( +/// What a grade sheet is written as, by the output file's extension. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// The grades alone. + Csv, + /// The grades, and the items, cases and record behind them on sheets of their own. + Xlsx, +} + +impl Format { + pub fn of(path: &Path) -> Result { + let extension = path + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase); + match extension.as_deref() { + Some("csv") => Ok(Format::Csv), + Some("xlsx") => Ok(Format::Xlsx), + _ => bail!( + "{} is not a .csv or .xlsx file: the grade sheet's extension says which to write", + path.display() + ), + } + } +} + +/// Revision `n` of `record` as a grade sheet in `format`. +pub fn sheet(record: &Record, n: u32, format: Format) -> Result> { + match format { + Format::Csv => { + let mut out = Vec::new(); + write_csv(&grades(record, n)?, &mut out)?; + Ok(out) + } + Format::Xlsx => workbook(&[ + grades(record, n)?, + items(record, n)?, + cases(&record.view(Some(n))?.reports), + about(record, n)?, + ]), + } +} + +/// One cell of a sheet. +#[derive(Debug, Clone, PartialEq)] +pub enum Cell { + Empty, + Text(String), + /// Rounded to `decimals` places, half away from zero; CSV prints it with [`number`]. + Number { + value: f64, + decimals: u8, + }, +} + +/// What one spreadsheet cell holds, in UTF-16 units: Excel's limit. +const CELL_LIMIT: usize = 32_767; + +/// Where a text too long for one cell was cut. The record keeps all of it. +const CUT: &str = " … (cut)"; + +impl Cell { + /// Text, cut to what one spreadsheet cell holds. + pub fn text(text: impl Into) -> Cell { + let text = text.into(); + if text.encode_utf16().count() <= CELL_LIMIT { + return Cell::Text(text); + } + let room = CELL_LIMIT - CUT.encode_utf16().count(); + let mut used = 0; + let end = text + .char_indices() + .find(|(_, c)| { + used += c.len_utf16(); + used > room + }) + .map_or(text.len(), |(i, _)| i); + Cell::Text(format!("{}{CUT}", &text[..end])) + } + + pub fn number(x: f64, decimals: u8) -> Cell { + let value = round_half_away(x, decimals); + Cell::Number { + // `-0` would read as a deduction that is not there. + value: if value == 0.0 { 0.0 } else { value }, + decimals, + } + } + + fn maybe(text: Option>) -> Cell { + text.map_or(Cell::Empty, Cell::text) + } + + /// A snake_case word, as the record writes it. + fn word(value: Option) -> Cell { + Cell::maybe(value.map(|v| word(&v)).filter(|w| !w.is_empty())) + } + + /// What CSV writes. + pub fn csv(&self) -> String { + match self { + Cell::Empty => String::new(), + Cell::Text(text) => text.clone(), + Cell::Number { value, decimals } => number(*value, *decimals), + } + } +} + +/// A sheet: a header, and rows of cells under it. +#[derive(Debug, Clone, PartialEq)] +pub struct Table { + pub name: &'static str, + pub header: Vec, + pub rows: Vec>, + /// Leading columns that say whose a row is, kept in view while scrolling. + pub keys: u16, +} + +fn header(names: &[&str]) -> Vec { + names.iter().map(|n| n.to_string()).collect() +} + +/// Which grading a row is from. Written on every row of the grades, so that a row copied +/// into another sheet still says. +struct Origin<'a> { + assignment: &'a str, + revision: u32, + /// The evidence digest's first 12 characters, as `grades push` prints it. + evidence: &'a str, +} + +/// One row per student, in the record's order, as revision `n` scored them: the grade, why +/// there is none or why it is a policy zero, each item's score and state, what lint earned +/// when the policy counts it, and which grading this is. +pub fn grades(record: &Record, n: u32) -> Result { + let revision = record.revision(n)?; + grade_table( + &record.view(Some(n))?.reports, + &revision.policy.items, + revision.policy.grading.lint_points, + &Origin { + assignment: &record.evidence.assignment.name, + revision: n, + evidence: &record.digest[..12], + }, + ) +} + +fn grade_table( reports: &[StudentReport], items: &[GradingItem], - out: W, -) -> Result<()> { - let mut csv = csv::Writer::from_writer(out); - let mut header: Vec = [ + lint_points: Option, + origin: &Origin, +) -> Result
{ + let mut header = header(&[ "student_id", "student_name", "canvas_user_id", - "grade", + "state", "reason", "score", "max", "raw_grade", "final_grade", - ] - .map(String::from) - .to_vec(); + ]); for item in items { header.extend([ format!("{}_score", item.id), @@ -83,8 +236,14 @@ pub fn write_grades_csv( format!("{}_reason", item.id), ]); } - csv.write_record(&header)?; + if lint_points.is_some() { + header.push("lint".into()); + } + header.extend(["assignment", "revision", "evidence"].map(String::from)); + let points = |x: f64| Cell::number(x, POINTS_DECIMALS); + let reason = |r: Option| Cell::word(r); + let mut rows = Vec::with_capacity(reports.len()); for report in reports { let Some(grade) = &report.grade else { bail!( @@ -92,9 +251,6 @@ pub fn write_grades_csv( report.student_id ); }; - let grade_cell = |x: f64| number(x, grade.basis.decimals); - let points_cell = |x: f64| number(x, POINTS_DECIMALS); - let reason = |r: Option| r.map(|r| word(&r)).unwrap_or_default(); let (state, score, raw, fin) = match grade.outcome { GradeOutcome::Graded { score, @@ -103,25 +259,20 @@ pub fn write_grades_csv( .. } => ( "graded", - points_cell(score), - grade_cell(raw_grade), - grade_cell(final_grade), + points(score), + Cell::number(raw_grade, grade.basis.decimals), + Cell::number(final_grade, grade.basis.decimals), ), - GradeOutcome::Withheld { .. } => { - ("withheld", String::new(), String::new(), String::new()) - } + GradeOutcome::Withheld { .. } => ("withheld", Cell::Empty, Cell::Empty, Cell::Empty), }; let mut row = vec![ - report.student_id.clone(), - report.student_name.clone().unwrap_or_default(), - report - .canvas_user_id - .map(|u| u.to_string()) - .unwrap_or_default(), - state.to_string(), + Cell::text(&report.student_id), + Cell::maybe(report.student_name.as_deref()), + Cell::maybe(report.canvas_user_id.map(|u| u.to_string())), + Cell::text(state), reason(grade.reason()), score, - points_cell(grade.max), + points(grade.max), raw, fin, ]; @@ -129,33 +280,287 @@ pub fn write_grades_csv( match grade.items.iter().find(|s| s.item_id == item.id) { Some(scored) => match &scored.outcome { ItemOutcome::Graded { score, reason: r } => { - row.extend([points_cell(*score), "graded".into(), reason(*r)]) + row.extend([points(*score), Cell::text("graded"), reason(*r)]) } ItemOutcome::Withheld { reason: r, .. } => { - row.extend([String::new(), "withheld".into(), reason(Some(*r))]) + row.extend([Cell::Empty, Cell::text("withheld"), reason(Some(*r))]) } }, // The student was withheld before any item was looked at. - None => row.extend([String::new(), String::new(), String::new()]), + None => row.extend([Cell::Empty, Cell::Empty, Cell::Empty]), } } - csv.write_record(&row)?; + if let Some(of) = lint_points { + // Lint counts only where the evidence was scored. + let earned = grading::gate(report) + .is_none() + .then(|| grading::lint_earned(of, report.lint.as_ref())) + .flatten(); + row.push(earned.map_or(Cell::Empty, points)); + } + row.extend([ + Cell::text(origin.assignment), + Cell::number(f64::from(origin.revision), 0), + Cell::text(origin.evidence), + ]); + rows.push(row); + } + Ok(Table { + name: "grades", + header, + rows, + keys: 2, + }) +} + +/// What each item column is: the items revision `n` scored, in declaration order, and lint +/// when the policy counts it. +pub fn items(record: &Record, n: u32) -> Result
{ + let policy = &record.revision(n)?.policy; + let mut rows: Vec> = policy + .items + .iter() + .map(|item| { + vec![ + Cell::text(&item.id), + Cell::maybe(item.title.as_deref()), + Cell::number(f64::from(item.points), 0), + Cell::word(Some(item.aggregation)), + ] + }) + .collect(); + if let Some(points) = policy.grading.lint_points { + rows.push(vec![ + Cell::text("lint"), + Cell::Empty, + Cell::number(f64::from(points), 0), + Cell::Empty, + ]); + } + Ok(Table { + name: "items", + header: header(&["item", "title", "points", "aggregation"]), + rows, + keys: 1, + }) +} + +/// The evidence behind the grades: one row per test case, and one for a student who has +/// none, saying why — so every student appears. +pub fn cases(reports: &[StudentReport]) -> Table { + let mut rows = Vec::new(); + for report in reports { + let student = || { + [ + Cell::text(&report.student_id), + Cell::maybe(report.student_name.as_deref()), + Cell::word(Some(report.submission_state)), + ] + }; + let before = rows.len(); + for result in &report.test_results { + for case in &result.cases { + let mut row = student().to_vec(); + row.extend([ + Cell::text(&result.item_id), + Cell::text(&case.case_name), + Cell::word(Some(case.status)), + Cell::maybe(case.actual.as_deref()), + Cell::maybe(case.expected.as_deref()), + Cell::maybe(case.failure.as_ref().map(|f| f.message.as_str())), + case.elapsed_ms + .map_or(Cell::Empty, |ms| Cell::number(ms as f64, 0)), + Cell::word(case.fault), + Cell::word(case.cause), + ]); + rows.push(row); + } + } + if rows.len() == before { + let (status, why) = match &report.error { + Some(error) => ("error".to_string(), Cell::text(error)), + None => ( + word(&report.status()), + Cell::word(report.grade.as_ref().and_then(|g| g.reason())), + ), + }; + let mut row = student().to_vec(); + row.extend([ + Cell::Empty, + Cell::Empty, + Cell::text(status), + Cell::Empty, + Cell::Empty, + why, + Cell::Empty, + Cell::Empty, + Cell::Empty, + ]); + rows.push(row); + } + } + Table { + name: "cases", + header: header(&[ + "student_id", + "student_name", + "submission_state", + "item_id", + "case_name", + "status", + "actual", + "expected", + "message", + "elapsed_ms", + "fault", + "cause", + ]), + rows, + keys: 2, + } +} + +/// Which grading a workbook is: the assignment, the record and revision, the inputs and +/// builds, and the policy the revision was scored under. +pub fn about(record: &Record, n: u32) -> Result
{ + let revision = record.revision(n)?; + let evidence = &record.evidence; + let path = |p: &Path| Cell::text(p.display().to_string()); + let count = |x: usize| Cell::number(x as f64, 0); + let mut rows: Vec<(String, Cell)> = vec![ + ("assignment".into(), Cell::text(&evidence.assignment.name)), + ( + "canvas_course_id".into(), + Cell::maybe(evidence.assignment.canvas_course_id.map(|i| i.to_string())), + ), + ( + "canvas_assignment_id".into(), + Cell::maybe( + evidence + .assignment + .canvas_assignment_id + .map(|i| i.to_string()), + ), + ), + ("revision".into(), Cell::number(f64::from(n), 0)), + ("revisions".into(), count(record.revisions.len())), + ("evidence".into(), Cell::text(&record.digest)), + ("revision_checksum".into(), Cell::text(&revision.checksum)), + ("students".into(), count(evidence.students.len())), + ("tests".into(), path(&evidence.inputs.tests)), + ( + "assignment_toml".into(), + Cell::maybe( + evidence + .inputs + .assignment + .as_ref() + .map(|p| p.display().to_string()), + ), + ), + ]; + match &evidence.inputs.source { + Source::Local { dirs, roster } => { + let dirs: Vec = dirs.iter().map(|d| d.display().to_string()).collect(); + rows.push(("submissions".into(), Cell::text(dirs.join("\n")))); + rows.push(( + "roster".into(), + Cell::maybe(roster.as_ref().map(|p| p.display().to_string())), + )); + } + Source::Canvas { bundle } => rows.push(("canvas_bundle".into(), path(bundle))), + } + rows.extend([ + ("run_by".into(), Cell::text(&evidence.scriptmark)), + ("scored_by".into(), Cell::text(&revision.scriptmark)), + ( + "derived_items".into(), + Cell::text(revision.policy.derived_items.to_string()), + ), + ]); + // The policy, as `[grading]` spells it. + let grading = serde_json::to_value(&revision.policy.grading) + .context("the grading policy is plain JSON")?; + for (key, value) in grading.as_object().into_iter().flatten() { + let cell = match value { + Value::Null => Cell::Empty, + Value::String(s) => Cell::text(s), + other => Cell::text(other.to_string()), + }; + rows.push((format!("grading.{key}"), cell)); + } + Ok(Table { + name: "record", + header: header(&["field", "value"]), + rows: rows + .into_iter() + .map(|(field, value)| vec![Cell::Text(field), value]) + .collect(), + keys: 1, + }) +} + +/// Write `table` as CSV, after a UTF-8 byte order mark: without one, spreadsheet software +/// reads Chinese names in some other encoding. +pub fn write_csv(table: &Table, mut out: W) -> Result<()> { + out.write_all("\u{feff}".as_bytes())?; + let mut csv = csv::Writer::from_writer(out); + csv.write_record(&table.header)?; + for row in &table.rows { + csv.write_record(row.iter().map(Cell::csv))?; } csv.flush()?; Ok(()) } +/// `tables` as one workbook, a sheet each, with a bold header kept in view, a filter on it +/// and the columns that say whose a row is frozen. +pub fn workbook(tables: &[Table]) -> Result> { + let mut workbook = Workbook::new(); + let bold = Style::new() + .set_bold() + .set_border_bottom(FormatBorder::Thin); + for table in tables { + let sheet = workbook.add_worksheet(); + sheet.set_name(table.name)?; + let last_col = u16::try_from(table.header.len().saturating_sub(1)) + .context("too many columns for a spreadsheet")?; + let last_row = + u32::try_from(table.rows.len()).context("too many rows for a spreadsheet")?; + for (col, title) in (0..).zip(&table.header) { + sheet.write_string_with_format(0, col, title, &bold)?; + } + for (row, cells) in (1..).zip(&table.rows) { + for (col, cell) in (0..).zip(cells) { + match cell { + Cell::Empty => {} + Cell::Text(text) => { + sheet.write_string(row, col, text)?; + } + Cell::Number { value, .. } => { + sheet.write_number(row, col, *value)?; + } + } + } + } + sheet.set_freeze_panes(1, table.keys)?; + sheet.autofilter(0, 0, last_row, last_col)?; + if last_row > 0 { + // A 学号 is text on purpose; Excel would flag every one. + sheet.ignore_error_range(1, 0, last_row, last_col, IgnoreError::NumberStoredAsText)?; + } + sheet.set_autofit_max_width(320).autofit(); + } + Ok(workbook.save_to_buffer()?) +} + /// Places points are shown to: finer than any grade, so item scores still add up. pub const POINTS_DECIMALS: u8 = 4; /// A number rounded half away from zero, as grades are, without trailing zeros: `87.5`, /// `0`, `66.67`. pub fn number(x: f64, decimals: u8) -> String { - let s = format!( - "{:.*}", - usize::from(decimals), - crate::grading::round_half_away(x, decimals) - ); + let s = format!("{:.*}", usize::from(decimals), round_half_away(x, decimals)); let s = if s.contains('.') { s.trim_end_matches('0').trim_end_matches('.') } else { @@ -179,7 +584,7 @@ mod tests { } use crate::models::fixtures::{graded, withheld}; - use crate::models::{Grade, SubmissionOutcome}; + use crate::models::{Aggregation, Grade, ItemScore, LintOutcome, SubmissionOutcome}; fn with_canvas(mut report: StudentReport, uid: u64) -> StudentReport { report.canvas_user_id = Some(uid); @@ -224,8 +629,20 @@ mod tests { assert!(grades_to_push(&shared).is_err()); } + const ORIGIN: Origin = Origin { + assignment: "hw", + revision: 2, + evidence: "0123456789ab", + }; + + fn csv(table: &Table) -> String { + let mut out = Vec::new(); + write_csv(table, &mut out).unwrap(); + String::from_utf8(out).unwrap() + } + #[test] - fn test_grades_csv_leaves_withheld_empty_and_writes_zero_with_its_reason() { + fn test_grades_leave_withheld_empty_and_write_zero_with_its_reason() { let items = [GradingItem::new("q1")]; let mut policy_zero = graded("bob", 0.0); if let Some(Grade { @@ -244,17 +661,141 @@ mod tests { Reason::EnvironmentFault, ), ]; - let mut out = Vec::new(); - write_grades_csv(&reports, &items, &mut out).unwrap(); - let text = String::from_utf8(out).unwrap(); + let text = csv(&grade_table(&reports, &items, None, &ORIGIN).unwrap()); + let text = text.strip_prefix('\u{feff}').expect("a byte order mark"); let lines: Vec<&str> = text.lines().collect(); assert_eq!( lines[0], - "student_id,student_name,canvas_user_id,grade,reason,score,max,raw_grade,final_grade,\ - q1_score,q1_state,q1_reason" + "student_id,student_name,canvas_user_id,state,reason,score,max,raw_grade,final_grade,\ + q1_score,q1_state,q1_reason,assignment,revision,evidence" + ); + assert_eq!( + lines[1], + "alice,,,graded,,0.875,1,87.5,87.5,,,,hw,2,0123456789ab" + ); + assert_eq!( + lines[2], + "bob,,,graded,not_submitted,0,1,0,0,,,,hw,2,0123456789ab" + ); + assert_eq!( + lines[3], + "carol,,,withheld,environment_fault,,1,,,,,,hw,2,0123456789ab" + ); + } + + #[test] + fn test_items_and_lint_add_up_to_the_score() { + let items = [GradingItem::new("q1"), GradingItem::new("q2")]; + let scored = |id: &str, score: f64, reason: Option| ItemScore { + item_id: id.into(), + points: 1, + aggregation: Aggregation::Proportional, + passed: 0, + cases: 3, + outcome: ItemOutcome::Graded { score, reason }, + }; + let mut report = graded("0012345", 0.0); + report.student_name = Some("张三".into()); + report.lint = Some(LintOutcome::Scored { score: 50.0 }); + if let Some(grade) = &mut report.grade { + grade.outcome = GradeOutcome::Graded { + score: 1.0 / 3.0 + 0.0 + 0.5, + raw_grade: 27.78, + final_grade: 27.78, + reason: None, + }; + grade.max = 3.0; + grade.items = vec![ + scored("q1", 1.0 / 3.0, None), + scored("q2", 0.0, Some(Reason::MissingFile)), + ]; + } + let table = grade_table(&[report], &items, Some(1), &ORIGIN).unwrap(); + let column = |name: &str| table.header.iter().position(|h| h == name).unwrap(); + let row = &table.rows[0]; + assert_eq!(row[column("student_id")], Cell::Text("0012345".into())); + assert_eq!(row[column("student_name")], Cell::Text("张三".into())); + assert_eq!(row[column("q2_reason")], Cell::Text("missing_file".into())); + let value = |name: &str| match row[column(name)] { + Cell::Number { value, .. } => value, + ref other => panic!("{name} is {other:?}"), + }; + assert_eq!(value("lint"), 0.5); + let sum = value("q1_score") + value("q2_score") + value("lint"); + // Each of the four numbers is rounded on its own. + assert!((sum - value("score")).abs() <= 4.0 * 0.5e-4, "{sum}"); + } + + #[test] + fn test_lint_is_shown_only_where_it_counted() { + let mut excused = withheld("0012346", SubmissionOutcome::Executable, Reason::Excused); + excused.excused = true; + excused.lint = Some(LintOutcome::Scored { score: 80.0 }); + let mut linted = graded("0012347", 100.0); + linted.lint = Some(LintOutcome::Scored { score: 80.0 }); + let table = grade_table(&[excused, linted], &[], Some(5), &ORIGIN).unwrap(); + let lint = table.header.iter().position(|h| h == "lint").unwrap(); + assert_eq!(table.rows[0][lint], Cell::Empty); + assert_eq!(table.rows[1][lint], Cell::number(4.0, POINTS_DECIMALS)); + } + + #[test] + fn test_cells_round_like_the_csv_and_fit_one_cell() { + assert_eq!( + Cell::number(-0.00001, POINTS_DECIMALS), + Cell::Number { + value: 0.0, + decimals: POINTS_DECIMALS + } + ); + assert_eq!(Cell::number(2.0 / 3.0, POINTS_DECIMALS).csv(), "0.6667"); + let Cell::Text(long) = Cell::text("文".repeat(40_000)) else { + panic!() + }; + assert_eq!(long.encode_utf16().count(), CELL_LIMIT); + assert!(long.ends_with(CUT)); + // A character outside the BMP is two UTF-16 units, and is never split. + let Cell::Text(wide) = Cell::text("😀".repeat(20_000)) else { + panic!() + }; + assert!(wide.encode_utf16().count() <= CELL_LIMIT); + assert_eq!(Cell::text("short"), Cell::Text("short".into())); + } + + #[test] + fn test_the_extension_picks_the_format() { + assert_eq!(Format::of(Path::new("out/g.csv")).unwrap(), Format::Csv); + assert_eq!(Format::of(Path::new("G.XLSX")).unwrap(), Format::Xlsx); + for refused in ["grades", "grades.xls", "grades.json"] { + assert!(Format::of(Path::new(refused)).is_err(), "{refused}"); + } + } + + #[test] + fn test_a_workbook_keeps_text_as_text_and_numbers_as_numbers() { + use calamine::{Data, Reader, Xlsx}; + let table = Table { + name: "grades", + header: header(&["student_id", "student_name", "score", "actual", "empty"]), + rows: vec![vec![ + Cell::text("0012345"), + Cell::text("张三"), + Cell::number(2.0 / 3.0, POINTS_DECIMALS), + Cell::text("\u{1b}[31m=1+1\0"), + Cell::Empty, + ]], + keys: 2, + }; + let bytes = workbook(&[table]).unwrap(); + let mut book = Xlsx::new(std::io::Cursor::new(bytes)).unwrap(); + let range = book.worksheet_range("grades").unwrap(); + assert_eq!(range.get((1, 0)), Some(&Data::String("0012345".into()))); + assert_eq!(range.get((1, 1)), Some(&Data::String("张三".into()))); + assert_eq!(range.get((1, 2)), Some(&Data::Float(0.6667))); + assert_eq!( + range.get((1, 3)), + Some(&Data::String("\u{1b}[31m=1+1\0".into())) ); - assert_eq!(lines[1], "alice,,,graded,,0.875,1,87.5,87.5,,,"); - assert_eq!(lines[2], "bob,,,graded,not_submitted,0,1,0,0,,,"); - assert_eq!(lines[3], "carol,,,withheld,environment_fault,,1,,,,,"); + assert!(matches!(range.get((1, 4)), None | Some(Data::Empty))); } } diff --git a/src/crates/scriptmark/src/grading.rs b/src/crates/scriptmark/src/grading.rs index b312229..5611cbf 100644 --- a/src/crates/scriptmark/src/grading.rs +++ b/src/crates/scriptmark/src/grading.rs @@ -170,6 +170,37 @@ pub fn round_half_away(x: f64, decimals: u8) -> f64 { (x * factor).round() / factor } +/// Why a student's evidence is never scored: who they are, and whether anything arrived, +/// decide before any evidence does. +pub fn gate(report: &StudentReport) -> Option { + if report.excused { + Some(Reason::Excused) + } else if report.error.is_some() { + Some(Reason::GradingTaskFailed) + } else { + match report.submission_state { + SubmissionOutcome::ReceivedUnmatched => Some(Reason::PendingReview), + SubmissionOutcome::NotSubmitted => Some(Reason::NotSubmitted), + SubmissionOutcome::SubmittedEmpty => Some(Reason::SubmittedEmpty), + SubmissionOutcome::Executable => None, + } + } +} + +/// What lint earns of the `points` it is worth: `None` when the tool did not run properly, +/// which withholds the grade. +pub fn lint_earned(points: u32, lint: Option<&LintOutcome>) -> Option { + match lint { + Some(LintOutcome::Scored { score }) if score.is_finite() => { + Some(f64::from(points) * score.clamp(0.0, 100.0) / 100.0) + } + // The linted item's file is missing, and `missing_file` already decided that item: + // withheld, or a 0 — which is what lint earns too. + Some(LintOutcome::NoFile) => Some(0.0), + Some(LintOutcome::Scored { .. } | LintOutcome::Failed { .. }) | None => None, + } +} + /// Score every report against `items`, replacing any earlier grade. /// /// Each report is scored on its own evidence alone. Errs only when the evidence breaks @@ -209,20 +240,7 @@ fn grade_one( detail: None, }; - // Who the student is, and whether anything arrived, decide before any evidence does. - let gate = if report.excused { - Some(Reason::Excused) - } else if report.error.is_some() { - Some(Reason::GradingTaskFailed) - } else { - match report.submission_state { - SubmissionOutcome::ReceivedUnmatched => Some(Reason::PendingReview), - SubmissionOutcome::NotSubmitted => Some(Reason::NotSubmitted), - SubmissionOutcome::SubmittedEmpty => Some(Reason::SubmittedEmpty), - SubmissionOutcome::Executable => None, - } - }; - if let Some(reason) = gate { + if let Some(reason) = gate(report) { let policy_zero = matches!(reason, Reason::NotSubmitted | Reason::SubmittedEmpty) && policy.config.missing == MissingPolicy::Zero; if !policy_zero { @@ -275,16 +293,9 @@ fn grade_one( .sum(); let mut lint_withheld = None; if let Some(points) = policy.config.lint_points { - match &report.lint { - Some(LintOutcome::Scored { score: lint }) if lint.is_finite() => { - score += f64::from(points) * lint.clamp(0.0, 100.0) / 100.0; - } - // The linted item's file is missing, and `missing_file` already decided that - // item: withheld, or a 0 — which is what lint earns too. - Some(LintOutcome::NoFile) => {} - Some(LintOutcome::Scored { .. } | LintOutcome::Failed { .. }) | None => { - lint_withheld = Some(Reason::LintFailed); - } + match lint_earned(points, report.lint.as_ref()) { + Some(earned) => score += earned, + None => lint_withheld = Some(Reason::LintFailed), } } diff --git a/src/crates/scriptmark/src/main.rs b/src/crates/scriptmark/src/main.rs index b1a765d..b77aa36 100644 --- a/src/crates/scriptmark/src/main.rs +++ b/src/crates/scriptmark/src/main.rs @@ -253,7 +253,9 @@ struct ExportArgs { /// Path to the grading record results: PathBuf, - /// Where to write the grades + /// Where to write the grades: a `.csv` of the grades alone, or a `.xlsx` with the + /// items, the cases behind the grades and the record they came from on sheets of + /// their own #[arg(short, long, default_value = "grades.csv")] output: PathBuf, @@ -614,14 +616,6 @@ fn shown(path: &Path, view: &View) -> String { } } -/// A fault or cause as the snake_case word the JSON results use; empty when absent. -fn label(value: Option) -> String { - value - .and_then(|v| serde_json::to_value(v).ok()) - .and_then(|v| v.as_str().map(str::to_string)) - .unwrap_or_default() -} - /// Prepare every test bundle, then run them against every student. A bundle that cannot /// be prepared stops the run before any student is graded, and so do fresh inputs that /// would replace other inputs frozen beside `output`. @@ -927,79 +921,11 @@ async fn cmd_grade(args: GradeArgs) -> Result<()> { frozen.write(&cases_path)?; println!("Inputs written to {}", cases_path.display()); } - scriptmark::export::write_grades_csv(reports, items, std::fs::File::create(&grades_path)?)?; + let grades = scriptmark::export::grades(&record, revision)?; + scriptmark::export::write_csv(&grades, std::fs::File::create(&grades_path)?)?; println!("Grades written to {}", grades_path.display()); - - let mut wtr = csv::Writer::from_path(&archive_path)?; - wtr.write_record([ - "student_name", - "student_id", - "submission_state", - "item_id", - "case_name", - "status", - "actual", - "expected", - "message", - "elapsed_ms", - "fault", - "cause", - ])?; - for report in reports { - let state = label(Some(report.submission_state)); - let mut rows = 0usize; - for test_result in &report.test_results { - for case in &test_result.cases { - rows += 1; - wtr.write_record([ - report.student_name.as_deref().unwrap_or(""), - &report.student_id, - &state, - &test_result.item_id, - &case.case_name, - &format!("{:?}", case.status), - case.actual.as_deref().unwrap_or(""), - case.expected.as_deref().unwrap_or(""), - case.failure - .as_ref() - .map(|f| f.message.as_str()) - .unwrap_or(""), - &case.elapsed_ms.map(|ms| ms.to_string()).unwrap_or_default(), - &label(case.fault), - &label(case.cause), - ])?; - } - } - // Every student gets at least one row, so the CSV covers the same cohort - // as the JSON archive rather than quietly dropping non-submitters. Its - // message says why there is no grade. - if rows == 0 { - let why = report - .error - .clone() - .unwrap_or_else(|| label(report.grade.as_ref().and_then(|g| g.reason()))); - wtr.write_record([ - report.student_name.as_deref().unwrap_or(""), - &report.student_id, - &state, - "", - "", - if report.error.is_some() { - "Error".to_string() - } else { - format!("{:?}", report.status()) - } - .as_str(), - "", - "", - &why, - "", - "", - "", - ])?; - } - } - wtr.flush()?; + let cases = scriptmark::export::cases(reports); + scriptmark::export::write_csv(&cases, std::fs::File::create(&archive_path)?)?; println!("Archived to {}", archive_path.display()); } @@ -1210,17 +1136,15 @@ fn cmd_summarize(args: SummarizeArgs) -> Result<()> { } fn cmd_export(args: ExportArgs) -> Result<()> { - let (_, view) = load_view(&args.results, args.revision.revision)?; + let format = scriptmark::export::Format::of(&args.output)?; + let (record, view) = load_view(&args.results, args.revision.revision)?; let revision = scored(&view, &args.results)?; + let sheet = scriptmark::export::sheet(&record, revision, format)?; if let Some(parent) = args.output.parent().filter(|p| !p.as_os_str().is_empty()) { std::fs::create_dir_all(parent)?; } - scriptmark::export::write_grades_csv( - &view.reports, - &view.items, - std::fs::File::create(&args.output) - .with_context(|| format!("failed to create {}", args.output.display()))?, - )?; + std::fs::write(&args.output, sheet) + .with_context(|| format!("failed to write {}", args.output.display()))?; println!( "Grades of revision {revision} written to {}", args.output.display() diff --git a/src/crates/scriptmark/tests/grade_sheet.rs b/src/crates/scriptmark/tests/grade_sheet.rs new file mode 100644 index 0000000..21de3a6 --- /dev/null +++ b/src/crates/scriptmark/tests/grade_sheet.rs @@ -0,0 +1,388 @@ +//! OSS-148: the grade sheet. `export` writes revision N of a grading record as CSV or as +//! XLSX from one table, so the two agree row for row and cell for cell: a 学号 stays text +//! with its leading zeros, scores are numbers, and a withheld grade is an empty cell where +//! a real zero is `0`. Run on `examples/bundles/grade_sheet`, as its comments say. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use calamine::{Data, Reader, Xlsx}; +use scriptmark::record::Record; + +fn example() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../examples/bundles/grade_sheet") +} + +fn copy(from: &Path, to: &Path) { + if from.is_dir() { + std::fs::create_dir_all(to).unwrap(); + for entry in std::fs::read_dir(from).unwrap() { + let entry = entry.unwrap(); + copy(&entry.path(), &to.join(entry.file_name())); + } + } else { + std::fs::copy(from, to).unwrap(); + } +} + +/// The example, graded with its roster. +fn graded() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + for name in [ + "assignment.toml", + "regrade.toml", + "roster.csv", + "tests", + "submissions", + ] { + copy(&example().join(name), &dir.path().join(name)); + } + scriptmark( + dir.path(), + &[ + "grade", + "submissions", + "-t", + "tests", + "-r", + "roster.csv", + "-o", + "out/results.json", + "--archive", + "out/archive", + ], + None, + ); + dir +} + +fn scriptmark(dir: &Path, args: &[&str], path: Option<&str>) -> String { + let mut command = Command::new(env!("CARGO_BIN_EXE_scriptmark")); + command.current_dir(dir).args(args); + if let Some(path) = path { + command.env("PATH", path); + } + let output = command.output().unwrap(); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(output.status.success(), "{args:?}: {text}"); + text +} + +/// A CSV as spreadsheet software reads it: a byte order mark, then rows of fields. +fn read_csv(path: &Path) -> Vec> { + let text = std::fs::read_to_string(path).unwrap(); + let text = text + .strip_prefix('\u{feff}') + .expect("a UTF-8 byte order mark"); + csv::ReaderBuilder::new() + .has_headers(false) + .from_reader(text.as_bytes()) + .records() + .map(|r| r.unwrap().iter().map(String::from).collect()) + .collect() +} + +fn read_xlsx(path: &Path, sheet: &str) -> Vec> { + let mut book: Xlsx<_> = calamine::open_workbook(path).unwrap(); + let range = book.worksheet_range(sheet).unwrap(); + assert_eq!(range.start(), Some((0, 0)), "{sheet} starts at A1"); + range.rows().map(<[Data]>::to_vec).collect() +} + +/// The same table: every row and every cell — a number in a `numeric` column as the +/// number the CSV prints, anything else as the same text, an empty cell as an empty field. +/// A 学号 that looks like a number must still be text, and a score must be a number. +fn assert_same(csv: &[Vec], xlsx: &[Vec], numeric: impl Fn(&str) -> bool) { + assert_eq!(csv.len(), xlsx.len(), "rows"); + let header = &csv[0]; + for (r, (fields, cells)) in csv.iter().zip(xlsx).enumerate() { + assert_eq!(fields.len(), cells.len(), "width of row {r}"); + for ((name, field), cell) in header.iter().zip(fields).zip(cells) { + let at = format!("{name} in row {r}"); + let number = r > 0 && numeric(name); + match cell { + Data::Empty => assert_eq!(field, "", "{at}"), + Data::String(s) if !number => assert_eq!(field, s, "{at}"), + Data::Float(x) if number => assert_eq!(field, &format!("{x}"), "{at}"), + Data::Int(i) if number => assert_eq!(field, &i.to_string(), "{at}"), + other => panic!("{at} is {other:?}, from {field:?}"), + } + } + } +} + +/// The grades table's numbers: points, grades and the revision. +fn grade_number(column: &str) -> bool { + matches!( + column, + "score" | "max" | "raw_grade" | "final_grade" | "lint" | "revision" + ) || column.ends_with("_score") +} + +fn column(table: &[Vec], name: &str) -> usize { + table[0] + .iter() + .position(|h| h == name) + .unwrap_or_else(|| panic!("no column {name} in {:?}", table[0])) +} + +/// The row of `student`, by column name. +fn row<'a>(table: &'a [Vec], student: &str) -> impl Fn(&str) -> &'a str { + let at = table + .iter() + .position(|r| r[0] == student) + .unwrap_or_else(|| panic!("no row for {student}")); + let header = &table[0]; + let fields = &table[at]; + move |name: &str| { + let col = header.iter().position(|h| h == name).unwrap(); + fields[col].as_str() + } +} + +/// What a sheet says once the one field that depends on where it was graded — the +/// evidence digest, which covers absolute paths and the interpreter — is set aside. +fn portable(table: &[Vec]) -> Vec> { + let mut table = table.to_vec(); + let evidence = column(&table, "evidence"); + for row in table.iter_mut().skip(1) { + row[evidence] = "".into(); + } + table +} + +/// The checks every revision's sheet passes. +fn assert_a_grade_sheet(dir: &Path, revision: u32) -> Vec> { + let n = revision.to_string(); + let csv_path = dir.join(format!("out/grades-{n}.csv")); + let xlsx_path = dir.join(format!("out/grades-{n}.xlsx")); + for path in [&csv_path, &xlsx_path] { + scriptmark( + dir, + &[ + "export", + "out/results.json", + "--revision", + &n, + "-o", + path.to_str().unwrap(), + ], + None, + ); + } + let csv = read_csv(&csv_path); + assert_same(&csv, &read_xlsx(&xlsx_path, "grades"), grade_number); + + // One row per student, in the record's order, and the same bytes every time. + let record = Record::load(&dir.join("out/results.json")).unwrap(); + let ids: Vec<&str> = csv[1..].iter().map(|r| r[0].as_str()).collect(); + let students: Vec<&str> = record + .evidence + .students + .iter() + .map(|s| s.student_id.as_str()) + .collect(); + assert_eq!(ids, students); + assert!(ids.is_sorted(), "{ids:?}"); + let again = dir.join("out/again.csv"); + scriptmark( + dir, + &[ + "export", + "out/results.json", + "--revision", + &n, + "-o", + "out/again.csv", + ], + None, + ); + assert_eq!( + std::fs::read(&again).unwrap(), + std::fs::read(&csv_path).unwrap() + ); + + // Which grading this is, on every row. + for row in &csv[1..] { + let field = |name| row[column(&csv, name)].as_str(); + assert_eq!(field("assignment"), "lab3 统计"); + assert_eq!(field("revision"), n); + assert_eq!(field("evidence"), &record.digest[..12]); + } + + // The items add up to the score, to the places a score is shown to. + let items = ["mean_score", "parity_score"]; + for row in csv[1..] + .iter() + .filter(|r| r[column(&csv, "state")] == "graded") + { + let value = |name| row[column(&csv, name)].parse::().unwrap(); + let sum: f64 = items.iter().map(|i| value(i)).sum(); + let bound = (items.len() + 1) as f64 * 0.5e-4; + assert!( + (sum - value("score")).abs() <= bound + f64::EPSILON, + "{row:?}" + ); + } + + // The workbook's other sheets: what each item is, the cases behind every grade, and + // where the grades came from. + let items = read_xlsx(&xlsx_path, "items"); + assert_eq!( + items[1..] + .iter() + .map(|r| (r[0].to_string(), r[1].to_string(), r[2].to_string())) + .collect::>(), + [ + ("mean".into(), "平均值".into(), "6".into()), + ("parity".into(), "奇偶判断".into(), "1".into()), + ] + ); + let cases = read_xlsx(&xlsx_path, "cases"); + for id in &ids { + assert!( + cases[1..] + .iter() + .any(|r| r[0] == Data::String(id.to_string())), + "no case rows for {id}" + ); + } + let about = read_xlsx(&xlsx_path, "record"); + let field = |name: &str| { + about + .iter() + .find(|r| r[0] == Data::String(name.into())) + .unwrap_or_else(|| panic!("no {name} in the record sheet"))[1] + .to_string() + }; + assert_eq!(field("evidence"), record.digest); + assert_eq!(field("revision"), n); + assert_eq!( + field("revision_checksum"), + record.revision(revision).unwrap().checksum + ); + csv +} + +#[test] +fn the_grade_sheet_is_one_table_as_csv_and_xlsx() { + let temp = graded(); + let dir = temp.path(); + + let csv = assert_a_grade_sheet(dir, 1); + let zhang = row(&csv, "0012301"); + assert_eq!( + (zhang("student_name"), zhang("state"), zhang("final_grade")), + ("张三", "graded", "100") + ); + let li = row(&csv, "0012302"); + assert_eq!( + (li("parity_score"), li("score"), li("final_grade")), + ("0.3333", "6.3333", "90.48") + ); + // Wrong answers: a real 0, with no reason beside it. + let wang = row(&csv, "0012303"); + assert_eq!( + ( + wang("state"), + wang("reason"), + wang("score"), + wang("final_grade") + ), + ("graded", "", "0", "0") + ); + // Nothing handed in: no grade, and why. + let zhao = row(&csv, "0012304"); + assert_eq!( + ( + zhao("state"), + zhao("reason"), + zhao("score"), + zhao("final_grade") + ), + ("withheld", "not_submitted", "", "") + ); + // On no roster: no grade until someone reviews it. + let unknown = row(&csv, "local:0012399"); + assert_eq!( + (unknown("state"), unknown("reason"), unknown("final_grade")), + ("withheld", "pending_review", "") + ); + assert_eq!( + portable(&csv), + portable(&read_csv(&example().join("expected/grades.csv"))) + ); + + // `grade --archive` writes the same grades, and the cases the workbook holds. + assert_eq!( + std::fs::read(dir.join("out/archive/grades_tests.csv")).unwrap(), + std::fs::read(dir.join("out/grades-1.csv")).unwrap() + ); + let cases = read_csv(&dir.join("out/archive/archive_tests.csv")); + assert_same( + &cases, + &read_xlsx(&dir.join("out/grades-1.xlsx"), "cases"), + |column| column == "elapsed_ms", + ); + // What a student printed reaches the sheet as it was, control characters and all. + let qian = cases + .iter() + .find(|r| r[0] == "0012305" && r[column(&cases, "status")] == "error") + .expect("0012305's mean raised"); + assert!( + qian[column(&cases, "message")].contains("\u{1b}[31m没有实现"), + "{qian:?}" + ); + + // Rescored with no interpreter on PATH: the missing submission is now a 0, and says why. + scriptmark( + dir, + &[ + "rescore", + "out/results.json", + "--assignment", + "regrade.toml", + ], + Some(""), + ); + let csv = assert_a_grade_sheet(dir, 2); + let zhao = row(&csv, "0012304"); + assert_eq!( + ( + zhao("state"), + zhao("reason"), + zhao("score"), + zhao("final_grade") + ), + ("graded", "not_submitted", "0", "0") + ); + assert_eq!( + portable(&csv), + portable(&read_csv(&example().join("expected/grades-rescored.csv"))) + ); +} + +#[test] +fn export_refuses_a_sheet_it_cannot_name() { + let temp = graded(); + let dir = temp.path(); + let output = Command::new(env!("CARGO_BIN_EXE_scriptmark")) + .current_dir(dir) + .args(["export", "out/results.json", "-o", "out/new/grades.ods"]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("is not a .csv or .xlsx file"), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !dir.join("out/new").exists(), + "refused before creating anything" + ); +} diff --git a/src/crates/scriptmark/tests/record.rs b/src/crates/scriptmark/tests/record.rs index b6cc39f..f7e83d3 100644 --- a/src/crates/scriptmark/tests/record.rs +++ b/src/crates/scriptmark/tests/record.rs @@ -208,7 +208,10 @@ fn the_archive_is_tables_and_the_json_is_the_record() { args.extend(["--archive", "out/archive"]); succeeded(scriptmark(dir, &args)); let cases = std::fs::read_to_string(dir.join("out/archive/archive_tests.csv")).unwrap(); - assert!(cases.starts_with("student_name,student_id,"), "{cases}"); + assert!( + cases.starts_with("\u{feff}student_id,student_name,"), + "{cases}" + ); let grades = std::fs::read_to_string(dir.join("out/archive/grades_tests.csv")).unwrap(); assert!( grades.contains("local:bob,,,graded,,5,10,50,50,"),