From 2371e2b53f4e9d683b7ba01f29ac6f4ea29dfdd4 Mon Sep 17 00:00:00 2001 From: Acture Date: Sun, 4 Oct 2026 03:25:25 +0800 Subject: [PATCH] feat: grade sheets as CSV and XLSX from one table (OSS-148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `export -o grades.csv|grades.xlsx` builds the sheet once as typed cells and writes either format from it, so the two agree cell for cell: identifiers and words are text, so a 学号 keeps its leading zeros, and scores are numbers rounded as the CSV prints them. Every row names its assignment, revision and evidence. The workbook adds the items, the cases behind each grade and the record it came from; the CSV gains a byte order mark for Chinese names. `grade` became `state`; `lint` joins the row when lint counts, so the items and lint add up to the score. `grade --archive` writes the same two tables. examples/bundles/grade_sheet and tests/grade_sheet.rs check both formats on leading-zero ids, Chinese names, thirds, a real zero, a missing and an unmatched submission, before and after rescoring. --- .gitignore | 3 + Cargo.toml | 4 + README.md | 4 + examples/bundles/grade_sheet/assignment.toml | 28 + .../grade_sheet/expected/grades-rescored.csv | 7 + .../bundles/grade_sheet/expected/grades.csv | 7 + examples/bundles/grade_sheet/regrade.toml | 18 + examples/bundles/grade_sheet/roster.csv | 6 + .../grade_sheet/submissions/0012301_lab.py | 6 + .../grade_sheet/submissions/0012302_lab.py | 6 + .../grade_sheet/submissions/0012303_lab.py | 6 + .../grade_sheet/submissions/0012305_lab.py | 6 + .../grade_sheet/submissions/0012399_lab.py | 6 + .../bundles/grade_sheet/tests/test_mean.toml | 17 + .../grade_sheet/tests/test_parity.toml | 22 + notes | 2 +- src/crates/scriptmark/Cargo.toml | 2 + src/crates/scriptmark/src/export.rs | 641 ++++++++++++++++-- src/crates/scriptmark/src/grading.rs | 59 +- src/crates/scriptmark/src/main.rs | 100 +-- src/crates/scriptmark/tests/grade_sheet.rs | 388 +++++++++++ src/crates/scriptmark/tests/record.rs | 5 +- 22 files changed, 1179 insertions(+), 164 deletions(-) create mode 100644 examples/bundles/grade_sheet/assignment.toml create mode 100644 examples/bundles/grade_sheet/expected/grades-rescored.csv create mode 100644 examples/bundles/grade_sheet/expected/grades.csv create mode 100644 examples/bundles/grade_sheet/regrade.toml create mode 100644 examples/bundles/grade_sheet/roster.csv create mode 100644 examples/bundles/grade_sheet/submissions/0012301_lab.py create mode 100644 examples/bundles/grade_sheet/submissions/0012302_lab.py create mode 100644 examples/bundles/grade_sheet/submissions/0012303_lab.py create mode 100644 examples/bundles/grade_sheet/submissions/0012305_lab.py create mode 100644 examples/bundles/grade_sheet/submissions/0012399_lab.py create mode 100644 examples/bundles/grade_sheet/tests/test_mean.toml create mode 100644 examples/bundles/grade_sheet/tests/test_parity.toml create mode 100644 src/crates/scriptmark/tests/grade_sheet.rs 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,"),