From 7bf00db69732d7777b3ef32c3b151d66cb2b1bd8 Mon Sep 17 00:00:00 2001 From: "robinwilliam.hundt" <robinwilliam.hundt@stud.uni-goettingen.de> Date: Thu, 10 Oct 2019 16:12:04 +0200 Subject: [PATCH] Added support for EmptyTest and preliminary support for .ipynb (WIP) --- Cargo.lock | 42 +++- Cargo.toml | 3 +- README.md | 10 +- src/anonymizer.rs | 4 +- src/lib.rs | 22 +- src/main.rs | 123 +++++++--- src/parser/ipynb_parser/example.ipynb | 110 +++++++++ src/parser/ipynb_parser/mod.rs | 1 + src/parser/ipynb_parser/notebook.rs | 320 ++++++++++++++++++++++++++ src/parser/mod.rs | 4 +- src/parser/xls_parser.rs | 233 ------------------- src/submission.rs | 22 +- src/submission_type.rs | 8 +- src/test_output.rs | 8 + src/testrunner/mod.rs | 42 ++++ tests/test_export.xls | Bin 12800 -> 0 bytes tests/test_xls_parser.rs | 180 --------------- tests/test_xml_parser.rs | 143 +++++++++++- 18 files changed, 800 insertions(+), 475 deletions(-) create mode 100644 src/parser/ipynb_parser/example.ipynb create mode 100644 src/parser/ipynb_parser/mod.rs create mode 100644 src/parser/ipynb_parser/notebook.rs delete mode 100644 src/parser/xls_parser.rs create mode 100644 src/test_output.rs create mode 100644 src/testrunner/mod.rs delete mode 100644 tests/test_export.xls delete mode 100644 tests/test_xls_parser.rs diff --git a/Cargo.lock b/Cargo.lock index 4e2532c..b15e8e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,6 +279,11 @@ dependencies = [ "synstructure 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "doc-comment" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + [[package]] name = "dtoa" version = "0.4.4" @@ -594,18 +599,15 @@ dependencies = [ [[package]] name = "mime" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -dependencies = [ - "unicase 2.4.0 (registry+https://github.com/rust-lang/crates.io-index)", -] [[package]] name = "mime_guess" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ - "mime 0.3.13 (registry+https://github.com/rust-lang/crates.io-index)", + "mime 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)", "unicase 2.4.0 (registry+https://github.com/rust-lang/crates.io-index)", ] @@ -931,7 +933,7 @@ dependencies = [ "hyper 0.12.33 (registry+https://github.com/rust-lang/crates.io-index)", "hyper-rustls 0.16.1 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", - "mime 0.3.13 (registry+https://github.com/rust-lang/crates.io-index)", + "mime 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)", "mime_guess 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)", "rustls 0.15.2 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)", @@ -996,7 +998,7 @@ dependencies = [ [[package]] name = "rusty-hektor" -version = "3.0.0" +version = "4.0.0" dependencies = [ "base64 0.10.1 (registry+https://github.com/rust-lang/crates.io-index)", "calamine 0.15.5 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1013,6 +1015,7 @@ dependencies = [ "serde 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)", "serde_derive 1.0.98 (registry+https://github.com/rust-lang/crates.io-index)", "serde_json 1.0.40 (registry+https://github.com/rust-lang/crates.io-index)", + "snafu 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "structopt 0.2.18 (registry+https://github.com/rust-lang/crates.io-index)", "sxd-document 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)", "sxd-xpath 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", @@ -1107,6 +1110,26 @@ name = "smallvec" version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" +[[package]] +name = "snafu" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "backtrace 0.3.34 (registry+https://github.com/rust-lang/crates.io-index)", + "doc-comment 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", + "snafu-derive 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "snafu-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "proc-macro2 0.4.30 (registry+https://github.com/rust-lang/crates.io-index)", + "quote 0.6.13 (registry+https://github.com/rust-lang/crates.io-index)", + "syn 0.15.44 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "spin" version = "0.5.1" @@ -1620,6 +1643,7 @@ dependencies = [ "checksum ct-logs 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "1b4660f8b07a560a88c02d76286edb9f0d5d64e495d2b0f233186155aa51be1f" "checksum derive_more 0.14.1 (registry+https://github.com/rust-lang/crates.io-index)" = "6d944ac6003ed268757ef1ee686753b57efc5fcf0ebe7b64c9fc81e7e32ff839" "checksum display_derive 0.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "4bba5dcd6d2855639fcf65a9af7bbad0bfb6dbf6fe68fba70bab39a6eb973ef4" +"checksum doc-comment 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "923dea538cea0aa3025e8685b20d6ee21ef99c4f77e954a30febbaac5ec73a97" "checksum dtoa 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)" = "ea57b42383d091c85abcc2706240b94ab2a8fa1fc81c10ff23c4de06e2a90b5e" "checksum either 1.5.2 (registry+https://github.com/rust-lang/crates.io-index)" = "5527cfe0d098f36e3f8839852688e63c8fff1c90b2b405aef730615f9a7bcf7b" "checksum encoding_rs 0.8.17 (registry+https://github.com/rust-lang/crates.io-index)" = "4155785c79f2f6701f185eb2e6b4caf0555ec03477cb4c70db67b465311620ed" @@ -1656,7 +1680,7 @@ dependencies = [ "checksum matches 0.1.8 (registry+https://github.com/rust-lang/crates.io-index)" = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" "checksum memchr 2.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "88579771288728879b57485cc7d6b07d648c9f0141eb955f8ab7f9d45394468e" "checksum memoffset 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "ce6075db033bbbb7ee5a0bbd3a3186bbae616f57fb001c485c7ff77955f8177f" -"checksum mime 0.3.13 (registry+https://github.com/rust-lang/crates.io-index)" = "3e27ca21f40a310bd06d9031785f4801710d566c184a6e15bad4f1d9b65f9425" +"checksum mime 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)" = "dd1d63acd1b78403cc0c325605908475dd9b9a3acbf65ed8bcab97e27014afcf" "checksum mime_guess 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "1a0ed03949aef72dbdf3116a383d7b38b4768e6f960528cd6a6044aa9ed68599" "checksum miniz_oxide 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "7108aff85b876d06f22503dcce091e29f76733b2bfdd91eebce81f5e68203a10" "checksum miniz_oxide_c_api 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "6c675792957b0d19933816c4e1d56663c341dd9bfa31cb2140ff2267c1d8ecf4" @@ -1711,6 +1735,8 @@ dependencies = [ "checksum serde_urlencoded 0.5.5 (registry+https://github.com/rust-lang/crates.io-index)" = "642dd69105886af2efd227f75a520ec9b44a820d65bc133a9131f7d229fd165a" "checksum slab 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)" = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8" "checksum smallvec 0.6.10 (registry+https://github.com/rust-lang/crates.io-index)" = "ab606a9c5e214920bb66c458cd7be8ef094f813f20fe77a54cc7dbfff220d4b7" +"checksum snafu 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "9d0bf93d08d6a44363b47d737f1f5bebbf5e6a1eaaa3d4c128ceeaca6b718292" +"checksum snafu-derive 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)" = "624e94bd38e471f67883b467711e7a7ad7dbe284f5fb7e661dc8a671fc5b26a0" "checksum spin 0.5.1 (registry+https://github.com/rust-lang/crates.io-index)" = "cbdb51a221842709c2dd65b62ad4b78289fc3e706a02c17a26104528b6aa7837" "checksum stable_deref_trait 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "dba1a27d3efae4351c8051072d619e3ade2820635c3958d826bfea39d59b54c8" "checksum string 0.2.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d24114bfcceb867ca7f71a0d3fe45d45619ec47a6fbfa98cb14e14250bfa5d6d" diff --git a/Cargo.toml b/Cargo.toml index e90e0c2..44d321f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rusty-hektor" -version = "3.0.0" +version = "4.0.0" authors = ["robinwilliam.hundt <robinwilliam.hundt@stud.uni-goettingen.de>"] license = "MIT OR Apache-2.0" description = "A tool to convert ILIAS exam output" @@ -32,3 +32,4 @@ reqwest = {version = "0.9.11", default-features = false, features = ["rustls-tls chrono = {version = "0.4.6", features = ["serde"]} semver = {version = "0.9.0", features = ["serde"]} display_derive = "0.0.0" +snafu = "0.5.0" diff --git a/README.md b/README.md index 01ecd98..c81ca2d 100644 --- a/README.md +++ b/README.md @@ -16,22 +16,18 @@ View the help message: rusty-hektor -h ``` -**NOTE**: The option to convert a `.xls` file has been deprecated and will probably be removed in the future. - -Convert an ILIAS `.xls` or `.zip` containing `.xml` import into `.json`: +Convert an ILIAS `.zip` (can be generated on ILIAS for an exam under `Export -> Erstelle Exportdatei (inkl. Teilnehmerergebnisse)`) containing `.xml` into `.json`: ``` -rusty-hektor -o <out_file_path> <path_to_(xls|zip)> +rusty-hektor -o <out_file_path> <path_to_zip> ``` -Converting an ILIAS Output into an anonymised json representation, storing the mapping file at `map.csv` (DO NOT LOOSE THIS FILE!). +Converting an ILIAS Output into an **anonymised** json representation, storing the mapping file at `map.csv` (DO NOT LOOSE THIS FILE!). ``` rusty-hektor -a --map-file map.csv -o <out_file_path> <path_to_(xls|zip)> ``` If `-o` is omitted, the output is printed to stdout. -If you call the program with the paths to an `.xml` and `.xls` export, both will be read and compared for inconsistencies. - By default, the program parses the free text questions and submissions of the `.xml` input. Should you wish to skip those questions simply call the program with the `--skip-text` flag. diff --git a/src/anonymizer.rs b/src/anonymizer.rs index 0f6e92e..bd53dcf 100644 --- a/src/anonymizer.rs +++ b/src/anonymizer.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; use crate::student::StudentSerializable; -const WORD_LIST: &'static str = include_str!("wordlist/eff_large_wordlist.txt"); +const WORD_LIST: &str = include_str!("wordlist/eff_large_wordlist.txt"); #[derive(Default)] pub struct StudentMap { @@ -20,7 +20,7 @@ struct StudentMapItem { impl StudentMap { pub fn into_csv_str(self) -> String { - let mut csv_str = format!("key;previous identifier;fullname\n"); + let mut csv_str = "key;previous identifier;fullname\n".to_string(); csv_str.push_str( &self .map diff --git a/src/lib.rs b/src/lib.rs index 10df75f..4efa1f5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,16 @@ #[macro_use] extern crate serde_derive; +use std::{io, io::Write}; +use std::collections::BTreeSet; use std::error::Error; use std::fmt::Debug; use std::result::Result; use std::str::FromStr; -use std::{io, io::Write}; + +pub use crate::exam::ExamSerializable; +use crate::student::StudentSerializable; +use crate::testrunner::Test; pub mod anonymizer; pub mod errors; @@ -16,8 +21,8 @@ pub mod parser; pub mod student; pub mod submission; pub mod submission_type; - -pub use crate::exam::ExamSerializable; +pub mod test_output; +pub mod testrunner; trait MergeOption<T> where @@ -62,3 +67,14 @@ where parsed.unwrap() } } + + +pub fn run_test(test: Box<dyn Test>, students: &mut [StudentSerializable]) { + for student in students { + student.submissions = student.submissions.clone().into_iter().map(|mut submission| { + let test_output = test.run(&submission); + submission.tests.insert(test_output.name.clone(), test_output); + submission + }).collect() + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 1ae4d54..4e75455 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ use std::error::Error; +use std::ffi::OsStr; use std::fs; use std::io::{self, Write}; -use std::path::PathBuf; use std::ops::Not; +use std::path::PathBuf; use chrono::{DateTime, Utc}; use env_logger::{Builder, Env}; @@ -10,18 +11,24 @@ use semver::Version; use serde_derive::Deserialize; use structopt::StructOpt; -use rusty_hektor::anonymizer; +use ParsedData::*; +use rusty_hektor::{anonymizer, run_test}; use rusty_hektor::exam::{Exam, ExamSerializable}; use rusty_hektor::module::Module; -use rusty_hektor::parser::{xls_parser::XLSParser, xml_parser::XMLParser}; use rusty_hektor::parser::{Parser, ParserError}; -use ParsedData::*; +use rusty_hektor::parser::ipynb_parser::notebook::Notebook; +use rusty_hektor::parser::xml_parser::XMLParser; +use rusty_hektor::student::StudentSerializable; +use rusty_hektor::submission_type::{ProgrammingLang, SubmissionType}; +use rusty_hektor::testrunner::empty_test::EmptyTest; +use rusty_hektor::testrunner::Test; /// Parse ILIAS exam export for importing into Grady #[derive(Debug, StructOpt)] struct Opt { - /// Provide either the path to the .xls (DEPRECATED), or zipped xml export or both - #[structopt(name = "IN_FILES", required = true, max_values = 2, parse(from_os_str))] + /// Provide the path to the zipped xml, in the future conversion and merge of multiple exports + /// of the same exam will be supported + #[structopt(name = "IN_FILE", required = true, max_values = 1, parse(from_os_str))] in_files: Vec<PathBuf>, /// Where to store the parsed data, prints to stdout if not provided @@ -40,13 +47,47 @@ struct Opt { #[structopt(long = "skip-text")] skip_text: bool, + /// Tests to run on the the submissions, the default (and only available option at + /// the moment) is "empty" to check for empty submissions + #[structopt(short, name = "TEST", parse(from_os_str), default_value = "empty")] + tests: Vec<TestEnum>, + /// Where to store the anonymisation map file, if enabled - #[structopt(short, long = "map-file", default_value = "anon.csv")] + #[structopt(short, long = "map-file", name="MAP_FILE_PATH", default_value = "anon.csv")] map_file_name: PathBuf, } + +#[derive(Clone, Debug)] +enum TestEnum { + Empty +} + +impl TestEnum { + fn as_test(&self) -> Box<dyn Test> { + match self { + Self::Empty => Box::new(EmptyTest {}) + } + } +} + +impl From<&OsStr> for TestEnum { + fn from(s: &OsStr) -> Self { + match s.to_str().expect("Non UTF-8 string in tests") { + "empty" => Self::Empty, + _ => panic!("Unable to parse test. Allowed values: empty") + } + } + +// fn from_str(s: &str) -> Result<Self, Self::Err> { +// match s { +// "empty" => Ok(Self::Empty), +// _ => Err("Unable to parse test. Allowed values: empty")? +// } +// } +} + enum ParsedData { - XLS(Exam), XML(Exam), } @@ -73,13 +114,11 @@ fn main() -> Result<(), Box<dyn Error>> { .extension() .expect("You need to provide a file with an .xls or .zip extension"); if extension == "xls" { - if yes_or_no( - "The XLS export conversion has been deprecated in favour \ - the more reliable XML export. Exit program?", - )? { - std::process::exit(1) - } - Ok(XLS(XLSParser::parse(path, parse_text)?)) + println!( + ".xls parsing has been removed due to the instability of the format.\ + Use zipped .xml instead." + ); + std::process::exit(1) } else if extension == "zip" { Ok(XML(XMLParser::parse(path, parse_text)?)) } else { @@ -95,17 +134,10 @@ fn main() -> Result<(), Box<dyn Error>> { let mut serializable = if parsed_data.len() == 1 { match parsed_data.pop().unwrap() { - XLS(data) => data.into_serializable()?, XML(data) => data.into_serializable()?, } } else { - let (xls_data, xml_data) = match (parsed_data.pop().unwrap(), parsed_data.pop().unwrap()) { - (XLS(xls_data), XML(xml_data)) => (xls_data, xml_data), - (XML(xml_data), XLS(xls_data)) => (xls_data, xml_data), - _ => panic!(), - }; - - merge_xls_xml_into_serializable(xls_data, xml_data)? + unreachable!("test") }; let mapping = match opt.anon { @@ -117,6 +149,11 @@ fn main() -> Result<(), Box<dyn Error>> { serializable = interactive_annotate(serializable)?; } + for test in opt.tests { + let test = test.as_test(); + run_test(test, &mut serializable.students); + } + let json = serde_json::to_string_pretty(&serializable)?; match opt.out_file { Some(path) => fs::write(path, json)?, @@ -138,15 +175,45 @@ fn interactive_annotate(mut data: ExamSerializable) -> Result<ExamSerializable, data.module = Some(Module::from_interactive()?); data.submission_types = data .submission_types + .clone() .into_iter() .map(|mut sub_type| { sub_type.interactive_annotate()?; + if sub_type + .programming_language + .as_ref() + .expect("Programming lan must not be None after annote") + == &ProgrammingLang::ipynb + { + for student in data.students.iter_mut() { + render_student_notebooks(student, &sub_type)?; + } + } Ok(sub_type) }) .collect::<Result<_, Box<dyn Error>>>()?; Ok(data) } +fn render_student_notebooks( + student: &mut StudentSerializable, + sub_type: &SubmissionType, +) -> Result<(), Box<dyn Error>> { + let rendered_submissions = student + .submissions + .clone() + .into_iter() + .map(|mut submission| { + if submission.r#type == sub_type.name { + submission.render_code::<Notebook>()?; + } + Ok(submission) + }) + .collect::<Result<_, Box<dyn Error>>>()?; + student.submissions = rendered_submissions; + Ok(()) +} + #[derive(Deserialize, Debug)] struct Release { tag_name: Version, @@ -178,16 +245,6 @@ fn check_for_new_version() -> Result<(), Box<dyn Error>> { Ok(()) } -fn merge_xls_xml_into_serializable( - mut xls_data: Exam, - mut xml_data: Exam, -) -> Result<ExamSerializable, Box<dyn Error>> { - xml_data.clean_student_identifier_username(); - xls_data.validate_and_merge_student_information(&mut xml_data)?; - let serializable = xls_data.into_serializable()?; - Ok(serializable) -} - fn yes_or_no(prompt: &str) -> io::Result<bool> { let mut buffer = String::new(); let allowed_responses = vec!["y", "Y", "n", "N", ""]; diff --git a/src/parser/ipynb_parser/example.ipynb b/src/parser/ipynb_parser/example.ipynb new file mode 100644 index 0000000..c92bda3 --- /dev/null +++ b/src/parser/ipynb_parser/example.ipynb @@ -0,0 +1,110 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "this is stdout\n", + "even mutliple\n" + ] + }, + { + "data": { + "text/plain": [ + "10" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "print(\"this is stdout\")\n", + "print(\"even mutliple\")\n", + "5+5" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'Execution result'" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "\"Execution result\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# a markdown cell!" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "ename": "ZeroDivisionError", + "evalue": "division by zero", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mZeroDivisionError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m<ipython-input-2-0106664d39e8>\u001b[0m in \u001b[0;36m<module>\u001b[0;34m()\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0;36m5\u001b[0m\u001b[0;34m/\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", + "\u001b[0;31mZeroDivisionError\u001b[0m: division by zero" + ] + } + ], + "source": [ + "5/0" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3.6", + "language": "python", + "name": "python3.6" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.2" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/parser/ipynb_parser/mod.rs b/src/parser/ipynb_parser/mod.rs new file mode 100644 index 0000000..0ac79e4 --- /dev/null +++ b/src/parser/ipynb_parser/mod.rs @@ -0,0 +1 @@ +pub mod notebook; diff --git a/src/parser/ipynb_parser/notebook.rs b/src/parser/ipynb_parser/notebook.rs new file mode 100644 index 0000000..b7ed3ae --- /dev/null +++ b/src/parser/ipynb_parser/notebook.rs @@ -0,0 +1,320 @@ +use core::fmt; +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::str::FromStr; + +use regex::Regex; +use lazy_static::lazy_static; + +/// Media attachments (e.g. inline images), stored as mimebundle keyed by filename. +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct Attachments(BTreeMap<String, Mimebundle>); + +/// A mime-type keyed dictionary of data +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct Mimebundle(BTreeMap<String, MultilineString>); + +impl Display for Mimebundle { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if let Some(content) = self.0.get("text/plain") { + write!(f, "{}", content) + } else { + write!( + f, + "Unsupported display type, please refer to source notebook." + ) + } + } +} + +/// Contents of the cell, represented as an array of lines. +pub type Source = MultilineString; + +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +#[serde(untagged)] +pub enum MultilineString { + SingleString(String), + MultiLine(Vec<String>), +} + +impl Display for MultilineString { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + use MultilineString::*; + + match self { + SingleString(single) => write!(f, "{}", single), + MultiLine(multi) => write!(f, "{}", multi.join("")), + } + } +} + +impl From<MultilineString> for String { + fn from(s: MultilineString) -> Self { + match s { + MultilineString::SingleString(single) => single, + MultilineString::MultiLine(multi) => multi.join("\n"), + } + } +} + +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct Notebook { + /// Array of cells of the current notebook. + pub cells: Vec<Cell>, + /// Notebook format (major number). Incremented between backwards incompatible changes to the + /// notebook format. + pub nbformat: i64, + /// Notebook format (minor number). Incremented for backward compatible changes to the notebook + /// format. + pub nbformat_minor: i64, +} + +impl FromStr for Notebook { + type Err = NotebookParseError; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + Ok(serde_json::from_str(s)?) + } +} + +impl Display for Notebook { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + for cell in &self.cells { + write!(f, "{}", cell)?; + } + Ok(()) + } +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(tag = "cell_type", rename_all = "lowercase")] +pub enum Cell { + Code(CodeCell), + Markdown(MarkdownCell), + Raw(RawCell), +} + +impl Display for Cell { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Code(cell) => write!(f, "{}", cell), + Self::Markdown(cell) => write!(f, "{}", cell), + Self::Raw(cell) => write!(f, "{}", cell), + } + } +} + +/// Notebook code cell. +#[serde(rename = "code_cell")] +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct CodeCell { + /// Execution, display, or stream outputs. + pub outputs: Vec<Output>, + pub source: Source, + /// The code cell's prompt number. Will be null if the cell has not been run. + #[serde(default)] + pub execution_count: Option<i64>, +} + +impl Display for CodeCell { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let exec_count_as_str = self + .execution_count + .and_then(|count| Some(count.to_string())) + .unwrap_or(" ".into()); + writeln!(f, "# In[{}]:\n", exec_count_as_str)?; + writeln!(f, "{}\n", self.source)?; + + writeln!(f, "# Out[{}]:\n", exec_count_as_str)?; + for output in &self.outputs { + write!(f, "{}", comment_out(output.to_string()))?; + } + Ok(()) + } +} + +/// Notebook markdown cell. +#[serde(rename = "markdown_cell")] +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct MarkdownCell { + pub attachments: Option<Attachments>, + pub source: Source, +} + +impl Display for MarkdownCell { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + writeln!(f, "# Markdown:\n{}", comment_out(self.source.to_string()))?; + if self.attachments.is_some() { + write!( + f, + "# Cell contains attachments, please refer to source notebook" + )?; + } + Ok(()) + } +} + +/// Notebook raw nbconvert cell. +#[serde(rename = "raw_cell")] +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct RawCell { + pub attachments: Option<Attachments>, + pub source: Source, +} + +impl Display for RawCell { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + writeln!(f, "# RawCell:\n{}", comment_out(self.source.to_string()))?; + if self.attachments.is_some() { + writeln!( + f, + "# Cell contains attachments, please refer to source notebook" + )?; + } + Ok(()) + } +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[serde(tag = "output_type")] +#[allow(non_camel_case_types)] +pub enum Output { + #[serde(rename = "execute_result")] + Execute(ExecuteResult), + #[serde(rename = "display_result")] + Display(DisplayData), + #[serde(rename = "stream")] + Stream(Stream), + #[serde(rename = "error")] + Error(ExecutionError), +} + +impl Display for Output { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Execute(out) => write!(f, "{}", out), + Self::Display(out) => write!(f, "{}", out), + Self::Stream(out) => write!(f, "{}", out), + Self::Error(out) => write!(f, "{}", out), + } + } +} + +/// Result of executing a code cell. +#[serde(rename = "execute_result")] +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct ExecuteResult { + pub data: Mimebundle, + #[serde(default)] + pub execution_count: Option<i64>, +} + +impl Display for ExecuteResult { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.data) + } +} + +/// Data displayed as a result of code cell execution. +#[serde(rename = "display_data")] +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct DisplayData { + pub data: Mimebundle, +} + +impl Display for DisplayData { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.data) + } +} + +/// Stream output from a code cell. +#[serde(rename = "stream")] +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct Stream { + /// The name of the stream (stdout, stderr). + pub name: String, + /// The stream's text output, represented as an array of strings. + pub text: MultilineString, +} + +impl Display for Stream { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.text) + } +} + +lazy_static! { + static ref STRIP_ASCII_COLOR: Regex = Regex::new("\u{001B}\\[[;\\d]*[ -/]*[@-~]").unwrap(); +} + +/// Output of an error that occurred during code cell execution. +#[serde(rename = "error")] +#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] +pub struct ExecutionError { + /// The name of the error. + pub ename: String, + /// The value, or message, of the error. + pub evalue: String, + /// The error's traceback, represented as an array of strings. + pub traceback: Vec<String>, +} + +impl Display for ExecutionError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + writeln!(f, "ERROR: {} {}\n", self.ename, self.evalue)?; + //TODO strip colouring from traceback + let traceback = self.traceback.join("\n"); + let traceback = STRIP_ASCII_COLOR.replace_all(&traceback, ""); + writeln!(f, "Traceback:\n{}", traceback) + } +} + +fn comment_out(input: impl AsRef<str>) -> String { + input + .as_ref() + .split("\n") + .map(|line| format!("# {}\n", line)) + .collect() +} + +#[derive(Debug)] +pub struct NotebookParseError { + source: serde_json::error::Error, +} + +impl Error for NotebookParseError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + Some(&self.source) + } +} + +impl fmt::Display for NotebookParseError { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), fmt::Error> { + writeln!( + f, + "Failed to parse Notebook. Source:\n{}", + self.source().unwrap() + ) + } +} + +impl From<serde_json::Error> for NotebookParseError { + fn from(err: serde_json::Error) -> Self { + Self { source: err } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + use std::io::BufReader; + + #[test] + fn can_parse_example_notebook() { + let reader = BufReader::new(File::open("src/parser/ipynb_parser/example.ipynb").unwrap()); + let data: Notebook = serde_json::from_reader(reader).unwrap(); + } +} diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e8ba936..ee2a313 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -3,11 +3,9 @@ use std::{error::Error, fmt, io, path::Path, result}; use crate::exam::Exam; -pub mod xls_parser; +pub mod ipynb_parser; pub mod xml_parser; -type Result<T> = std::result::Result<T, ParserError>; - pub trait Parser { fn parse<'a>(path: &Path, parse_text_questions: bool) -> result::Result<Exam, Box<dyn Error>>; } diff --git a/src/parser/xls_parser.rs b/src/parser/xls_parser.rs deleted file mode 100644 index dbf7965..0000000 --- a/src/parser/xls_parser.rs +++ /dev/null @@ -1,233 +0,0 @@ -use std::{ - collections::{BTreeMap, BTreeSet}, - error::Error, - iter, - path::Path, -}; - -use calamine::{open_workbook, DataType, Range, Reader, Xls}; - -use lazy_static::lazy_static; -use log::warn; -use regex::Regex; -use url::percent_encoding::percent_decode; - -use crate::{ - exam::Exam, - parser::{self, Parser, ParserError}, - student::Student, - submission::Submission, - submission_type::SubmissionType, -}; - -pub struct XLSParser {} - -impl XLSParser {} - -const OVERVIEW_SHEET_NAME: &'static str = "Testergebnisse"; -const SUBMISSION_IN_NEXT_ROW_MARKER: &'static str = "Quellcode Frage"; - -impl Parser for XLSParser { - fn parse(path: &Path, parse_text_questions: bool) -> Result<Exam, Box<dyn Error>> { - if parse_text_questions { - Err(ParserError::new( - "XLS Parser is deprecated and doesn't support parsing of text questions", - ))? - } - let mut workbook: Xls<_> = match open_workbook(path) { - Ok(workbook) => workbook, - Err(xls_err) => Err(ParserError::from(xls_err))?, - }; - let names: Vec<String> = workbook - .sheet_names() - .iter() - .filter(|s| s.as_str() != OVERVIEW_SHEET_NAME) - .map(|s| s.to_owned()) - .collect(); - - let ws_range = workbook - .worksheet_range(OVERVIEW_SHEET_NAME) - .expect("Unable to read overview sheet") - .expect("Unable to read overview sheet"); - let mut students = parse_test_results(ws_range)?; - for name in names { - let student = students.get_mut(&name).ok_or(ParserError::new(format!( - "{} occurs as sheet name but has no test results", - name - )))?; - let student_range = - match workbook - .worksheet_range(name.as_str()) - .ok_or(ParserError::new(format!( - "Unable to open sheet for {}", - name - )))? { - Ok(range) => range, - Err(xls_err) => Err(ParserError::from(xls_err))?, - }; - student.merge(&mut parse_student_sheet(student_range)?)?; - } - - let submission_types = extract_submission_types(students.values().collect())?; - let exam = Exam { - module: None, - students, - submission_types, - }; - Ok(exam) - } -} - -fn extract_submission_types(students: Vec<&Student>) -> parser::Result<BTreeSet<SubmissionType>> { - let mut submission_types: BTreeSet<String> = BTreeSet::new(); - - for stud in students.iter() { - let sub_types_for_stud: BTreeSet<String> = stud - .submissions - .as_ref() - .expect(&format!("{:?} seems to have no submissions", stud)) - .iter() - .map(|sub| sub.r#type.clone()) - .collect(); - if !submission_types.is_empty() && submission_types != sub_types_for_stud { - return Err(ParserError::new(format!( - "Student {} has differing submission types: {:?} != {:?}", - stud.fullname - .as_ref() - .unwrap_or(&"Error: No Name".to_owned()), - submission_types, - sub_types_for_stud - ))); - } else if submission_types.is_empty() { - submission_types = sub_types_for_stud; - } - } - Ok(submission_types - .into_iter() - .map(|type_name| SubmissionType::new(type_name)) - .collect()) -} - -/// Parses the first sheet of the xls file containing names and matrikelNo's -fn parse_test_results(data: Range<DataType>) -> parser::Result<BTreeMap<String, Student>> { - let mut students = BTreeMap::new(); - for row in data.rows().skip(1) { - let fullname = match &row[0] { - DataType::String(s) => s.to_owned(), - ty => { - return Err(ParserError::new(format!( - "Found Type {} in column 0, expected String", - ty - ))); - } - }; - let identifier = match &row[1] { - DataType::String(s) => parse_mat_no(s), - ty => { - return Err(ParserError::new(format!( - "Found Type {} in column 1, expected String", - ty - ))); - } - }; - let username: Option<String> = Some( - fullname - .chars() - .filter(|c| c.is_uppercase()) - .chain(identifier.clone().chars()) - .collect(), - ); - students.insert( - fullname.clone(), - Student { - fullname: Some(fullname), - identifier: Some(identifier), - username, - ..Student::default() - }, - ); - } - Ok(students) -} - -fn parse_student_sheet(data: Range<DataType>) -> parser::Result<Student> { - let mut student = Student { - submissions: Some(BTreeSet::new()), - ..Student::default() - }; - - for (top_row, bot_row) in data.rows().zip( - data.rows() - .skip(1) - .map(|row| Some(row)) - .chain(iter::repeat::<Option<&[DataType]>>(None)), - ) { - let submission = match &top_row[0] { - DataType::String(s) => match s.as_str() { - SUBMISSION_IN_NEXT_ROW_MARKER => parse_submission_in_sheet(top_row, bot_row)?, - _ => continue, - }, - _ => continue, - }; - match student.submissions { - Some(ref mut set) => set.insert(submission), - None => unreachable!(), - }; - } - Ok(student) -} - -fn parse_submission_in_sheet( - top_row: &[DataType], - bot_row: Option<&[DataType]>, -) -> parser::Result<Submission> { - let submission_type = match &top_row[1] { - DataType::String(s) => s.clone(), - ty => { - return Err(ParserError::new(format!( - "Found type {:?} instead of String for submission type name", - ty - ))); - } - }; - let code = match bot_row { - Some(row) => match &row[0] { - DataType::String(s) => percent_decode(s.as_bytes()) - .decode_utf8() - .expect(".xls file contains invalid utf8") - .into_owned(), - DataType::Empty => "".to_owned(), - ty => { - return Err(ParserError::new(format!( - "Found type {:?} instead of String or Empty for submission code", - ty - ))); - } - }, - None => "".to_owned(), - }; - Ok(Submission::new(code, submission_type)) -} - -fn parse_mat_no(input: &String) -> String { - lazy_static! { - static ref RE: Regex = Regex::new(r"^(?P<identifier>\d+)-(\d+)-(\d+)$").unwrap(); - } - let caps = match RE.captures(input) { - Some(caps) => caps, - None => { - warn!("Unable to find matrikel number in second column. Falling back to username"); - return input.to_owned(); - } - }; - match caps.name("identifier") { - Some(hit) => hit.as_str().to_owned(), - None => { - warn!( - "Did not match matrikel number. Expected format: {}", - RE.as_str() - ); - input.to_owned() - } - } -} diff --git a/src/submission.rs b/src/submission.rs index a6d0c32..a84afe9 100644 --- a/src/submission.rs +++ b/src/submission.rs @@ -1,26 +1,46 @@ +use serde::export::fmt::Display; use std::collections::BTreeMap; +use std::error::Error; use std::hash::{Hash, Hasher}; +use std::str::FromStr; +use crate::test_output::TestOutput; #[derive(Debug, Eq, PartialEq, Serialize, Default, Clone, PartialOrd, Ord)] pub struct Submission { pub code: String, + /// This field is populated if the displayed source code differs from the + /// source. We use this for the correction of `.ipynb` notebooks which we transform + /// into a python script to display but want to keep the original notebook as json + pub display_code: Option<String>, pub r#type: String, - pub tests: BTreeMap<(), ()>, + pub tests: BTreeMap<String, TestOutput>, } impl Submission { pub fn new(code: String, r#type: String) -> Self { Submission { code, + display_code: None, r#type, tests: BTreeMap::new(), } } + + pub fn render_code<T>(&mut self) -> Result<(), Box<dyn Error>> + where + T: FromStr + Display, + <T as FromStr>::Err: Error + 'static, + { + let intermediate: T = self.code.parse()?; + self.display_code = Some(intermediate.to_string()); + Ok(()) + } } impl Hash for Submission { fn hash<H: Hasher>(&self, state: &mut H) { self.code.hash(state); + self.display_code.hash(state); self.r#type.hash(state); } } diff --git a/src/submission_type.rs b/src/submission_type.rs index e216dd3..147f13e 100644 --- a/src/submission_type.rs +++ b/src/submission_type.rs @@ -7,7 +7,7 @@ use crate::input; use display_derive::Display; -#[derive(Debug, Eq, PartialEq, Hash, Serialize, Default, PartialOrd, Ord)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Default, PartialOrd, Ord)] pub struct SubmissionType { pub name: String, pub full_score: Option<u32>, @@ -73,18 +73,19 @@ impl SubmissionType { } #[allow(non_camel_case_types)] -#[derive(Debug, Serialize, Eq, PartialEq, Hash, PartialOrd, Ord)] +#[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash, PartialOrd, Ord)] pub enum ProgrammingLang { c, java, mipsasm, haskell, + ipynb, plaintext, } #[derive(Debug, Display)] #[display( - fmt = "Unparseable programming language: {}. Allowed: c, java, mipsasm, haskell, plaintext", + fmt = "Unparseable programming language: {}. Allowed: c, java, mipsasm, haskell, ipynb, plaintext", input )] pub struct ParseProgrammingLangError { @@ -103,6 +104,7 @@ impl FromStr for ProgrammingLang { "mipsasm" => ProgrammingLang::mipsasm, "haskell" => ProgrammingLang::haskell, "plaintext" => ProgrammingLang::plaintext, + "ipynb" => ProgrammingLang::ipynb, _ => Err(ParseProgrammingLangError { input: s.to_owned(), })?, diff --git a/src/test_output.rs b/src/test_output.rs new file mode 100644 index 0000000..ead9a43 --- /dev/null +++ b/src/test_output.rs @@ -0,0 +1,8 @@ + + +#[derive(Debug, Eq, PartialEq, Serialize, Default, Clone, PartialOrd, Ord)] +pub struct TestOutput { + pub name: String, + pub annotation: String, + pub label: String, +} diff --git a/src/testrunner/mod.rs b/src/testrunner/mod.rs new file mode 100644 index 0000000..e9b15ae --- /dev/null +++ b/src/testrunner/mod.rs @@ -0,0 +1,42 @@ +use crate::submission::Submission; +use crate::test_output::TestOutput; + +pub trait Test { + fn run(&self, submission: &Submission) -> TestOutput; +} + + +pub mod empty_test { + use crate::testrunner::Test; + use crate::submission::Submission; + use crate::test_output::TestOutput; + + enum Labels { + Empty, + NotEmpty + } + + impl Labels { + fn as_str(&self) -> &'static str { + match self { + Self::Empty => "EMPTY", + Self::NotEmpty => "NOT_EMPTY" + } + } + } + pub struct EmptyTest {} + + impl Test for EmptyTest { + fn run(&self, submission: &Submission) -> TestOutput { + let label = match submission.code.trim().len() { + 0 => Labels::Empty, + _ => Labels::NotEmpty + }; + TestOutput { + name: "EmptyTest".to_string(), + annotation: "".to_string(), + label: label.as_str().to_string() + } + } + } +} diff --git a/tests/test_export.xls b/tests/test_export.xls deleted file mode 100644 index f8f5266cb46ba5c1f15dc33d90a3ae3147f2e9b0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12800 zcmeHNYit}>6+W}OcGhtc+j%&43SnBBR}&|@c0wGZl8x)Msf5$en4;2FbnLyhw^`3d zv$IWN32;j*6^Tl%DghFapi(JRNIcXMBq$>JS3!BGY9YX%6tx0Wez*t(mC~B;yLZO3 zJN9C`EvQLzojWt<+;i^ZoO{1>X0pHeb=!r<f3V?YiItB^Qr?<rlEx~&h3EE=ZWURB zXY$^fnVAXMc)U_BAq%`2%ryxIrcfJD>GNf%Y1Agv<*3c5D^ORWu0m}=#TJk@)YYim z1FY+U=k>w;5;Dd6a;mpV34gxWa!_2{Jvjx>Exxp4$5b0`F`CtQu^9P<WUh=KSzsYs zvAx><O=+Cs#s>Mx%IA6H_1S+}67v1jIb22lgnFkOCpO&R6i4j;SLxSta4gQ@EP~AS zRvNJ;fvA*{)3~pa@9DN@_4Rs<K@iP}-K%4gCFhel$ca3xuh;17DqJo3k=FTx=454q zJO`<n&p*OY289Oe08T+c94Shy6$0;eYg(S=B<qelU+c!y3VDJEeUoyP=M*=lmdiJZ z+?k!3%%+X0PI*$+iVO_w8XVkpJIbB*p+kLxgMCMjUSI>1|2bph9+tB<`CPx||6_4{ z&U-%jdVKaepZw*^e*!kOxxAo+cHVC6FdgS_tHa+>hksoie$%}C2KZe#AMSUS)R$Yr z{h;{|88!}_-VcIJrTGuU`A^QvZw&jd>gTZpfMZH#cyFCb>U-N%qrR`6O6mKWsb%`U zcB*0KZhbT#M4GvdSqF~!kShDi8&kt>p<GrEBI(F7-#~J5)UV`~>u1Qbm~TY9EO}2~ zRm1E45}+fCzjJ_+bJ*-d4_opYjyh7A>3b+Djnf(4fQU?kFmqjbE?Bv*d1tD`JKK5h zk>+Vm>1P-}E%#YSnbK#i9IiRp_&Pdc(v(K>l*S~}|3d#4<y7hf_*qo+a@d(rXzo{T z#h+B33lfsm3%3ideWc<Z^Od_%WY9TTm~e`=@;p@-86S2$6|_z|<3(g(HL6arY#%Q9 zY6C}rf?u|WmE#TLN%_jY(R0R>3#FS>*_rU!gPm7iz`s`Hh*z3~nahqpX>Yx^R4kVL zSKNGItg4r7$O8L=J}d56Iqy`I_hBGV#TywfkBqwo-!EdO%AO+y(;W2ZVf#R3)T@jl zxBR&BCnE-&IG`r%L6t8!u02^9Qzb9&a^_qV=6$Q<j_DZ>sY20J;}gm&xF4ve<=K?) zoU`0Wc^3?92BR62H(5|c*gdc?Mk?FY_ARS2AhlneP#*2+*?Q4Mr!tCtF#C)>c88P6 z++`mvO(=i7;Eq8FoNN`xR>iXVpk{Z+*)}{-@Ko`XY47QR;_ao1eWK+0a73}7?o~GW zEn{Wqwx<g{43$duxZ~w_*kcY_E63N1bqB&)Ms`aV_TF7$%K-#BS2}DafMZAmUHmzC zHi_W!YK3mCu-V>hr)Bo+!SUT0Q@Xl)GTE-|zTH@>3ukj)W#4geMxl_&_GR|=^?Zbn znZC@vzMg$iH`%D0tm$TNZ?}=$v%4?T8z}AV+r2wd>W-AUjZ*JE-F>#NC)2lw-D9Ne z9t^gfn=FJ7``(hPJ(%b2q@(PtJ$lizLp;G2JPHRDm3_l_sW75!?9oHY!EVSqzA7je zj=jzC3eIp*xg8=0yn>Hi=NGWkm3_-`gk-<uDHkVyz$x2<rF^A$tmICr<38S)59B=s zAam`Co3}r$3S-KH!9Gz@#o|aQADqV}O*p$G>FyZ-Syo2U>rjC|LE(EFOM<X#lET)S zq;Lh)^1w<aP;D9uTY+{LpL+d;!QmtOKco3m;DlupkskngA6m|(uG1RZwbcFk$~=UF zenGc9ps$-}_5ZSeOV#t%+2`r~+p3-)muv8Xn&&Y1OUMpuht+)MZv4BjKfGJ=Nwh4N z&+6A#Sh^MFMj=hDN82Jgfi&`==7?T{PaLo9jOew<k`CJujrm)lyd?NqY$AsssRYy~ zi2eGRVFI!(nnwcdH9W?aIFE5A&eJxPO*OiVRTATw&o;GkI=_#}8GqHv@s&T`pAN<` zU49aX%kdLIoX1ZFah|bN?YPFSwLD|NS{}~*oc?r55St%APsPXOu8Z@`){n+`Q-AO< zyHmta7WOKQZm0w(ddyHsQ1q3dQlRK1Lp6Y+e+<<Kik>mlGEnr1q0*q}4MR17q8|*k z927lZsAf>Ky`ffsqSXzx5)|!is8yh7X+yPuqKysJ3X0Y>R2wMT)ljQJ(V~W01B$jZ z)LKxqqM_D-qWuhotWAF)G}L-f$YX-G4p0Egfa(N=P^Br_wOyYW>hVmr3sbkBVh%jj z?b>GT;zBSPXr#bJjE77IWEwyrFAA>fwfwR`G7T<*W<WIsGD)0P3!`<&RcG%0#S5EX z%^6NJIDfvs>->x0nAxm=h8bxH8Y@8^)+6oI*W2`!J`HDC{mebmu6IhkYp0%<k^2y& zIVKUdU2IUaKw@4CB(TTAd38Xdz6BE4Rqb;1Y~QrNHK4d&;$nwvd3D9lLklFZvpS^z zYZEVp7D!-sg(D3?<9}lTo}YF>%BU@n)E2lF6fKaP*8=<y5YCG-v;a}p?1V<Wz9i1q zdimp-3H*C)KW&@T*4Q*V4y~~n6s?ie*7)%=_x&ogMp9ej#O9}d8(Je-WsQDlyi;qK z^>ttt(O&74zsV6s+UY}>8KUeoae=3Vz|tZ~{g%=qsp+kn-!yGdk^)hIZ?ztEh(I4p ztd=vnITs1xWT3x<HXM%{o;Hl(HD!z=1_j%ZpO;8o?zzD$E2G#Bd(VmOCQ{H-VQi<A zwo?$>*_MdKA?|JPYvdRq;yijO&Lf({d5jrx9%Ds(TzborhjbXs1@mLvh|4j0Sn>_M z^iYm5BreBDV#&XC_zmT#6qn;}wB(2mwV@o9;&O~ImOR2kE1?`G8<%58mYgKOYdM}{ zmQLA%t3gagYo3M>Kck36>vzgFTtA20)*uV5N=bU5Q?3izQW(XYL$=Wyor2U_>owH0 zQNL4m1Z|BqZS+nj-mi?_vYIw}X`L`~aD*nbMX{7?ilQL>6iGH~$uQ2+Uy&p|6-lnt zlKf9PjzEt^lJr$1*`g)G_)MQglJr(2*`_7K*iElRlJr+3xkgKdah-lMlGS)-SK}FA zX32O4omo6<<V<s@VbOS|cRZ%T^j&~u?^is#Eb!}s2Hg-J>f_gQ+;+1#QXju+`>T&% zIK)-`{TKYwxE{{09)95yBa@pLe(eH>=M-p;Q*=4tSD3>tj9;PT!uS<RE{tEH<ihwB zO4h@#YQ)M^BNo7S$%qA=S;W$BSvmMSEE=(NtOR251H%=>qG^zivs{W9*Lgkl5E1Gl zR&6F#AF=d*OwJ-(eZ;DjtB+V;y_%K{=T{%GI%biV7{$_wZ^xG#VukOV3nNx2xiDgd zk_#hNC|M7&tVE;!64S2Z#A47YSB+Lk>z0gG(23BB8FCIaEE=uIWJ;dW3G@Q_Rj7I8 z{+_e|vzPF<Tl6bk&(|Bi#*oif7T&Lv%hLiDyqg5Nb9Jq0CZZx|^;K@<N7%u_h*v6? zMt%F>$q`kw_wDT(a_}FHMWwe%e_?dA*NDHpEB2om^zEr*_C%t&U2tDtc<}3gf8&<% z*6%!;mhD@f{yl?N8Vv++wjW2O=1J5f>I0}e1COBc9GphwS@<?8&%-mQc>C1$;kkGo zm4Wd*D#OPssC+q}M&%e6QQ7}r-xU3Mnq#u?OUAqP%>MVF=1}FLcaZbe&!kau`f4tG z{?_llc*p4<z8DT)*M96b@zO6}I=%C%*2lgA`|Wt+y93}g#CUgt$4>+7khK++x?FQT zKANwT_lO1F&EJ1&|Hm+7zGd<qXNsEy<x}|4fhWggSpN?7m|-sAYW{=7oLgepVpKV_ zV;f0JZotSBz=B&ahkFcj30IC!?BLiei&ecj5VSCEQPg79=E(8mff?Cc=LpQh;QGxQ zCvonmRIvUD{Op6j{k<7K02$RjA;-thL-?bR`ekb{K5=KU>!-bX0|aW20gN!B=b$i> hzr^|UzK!)0!~Ob8T5`)$?O*--+a(V+kHR|{|37AoNa_Fp diff --git a/tests/test_xls_parser.rs b/tests/test_xls_parser.rs deleted file mode 100644 index 8fdf846..0000000 --- a/tests/test_xls_parser.rs +++ /dev/null @@ -1,180 +0,0 @@ -use rusty_hektor::parser::{xls_parser::XLSParser, Parser}; -use rusty_hektor::student::StudentSerializable; -use std::collections::BTreeSet; -use std::collections::HashSet; -use std::error::Error; -use std::path::Path; - -#[test] -fn can_parse_xls_file() -> Result<(), Box<Error>> { - let parsed = XLSParser::parse(Path::new("tests/test_export.xls"), false)?; - let serializable = parsed.into_serializable()?; - - assert_eq!(3, serializable.students.len()); - assert_eq!(6, serializable.submission_types.len()); - assert_eq!(None, serializable.module); - - Ok(()) -} - -#[test] -fn parsed_xls_contains_coorect_submission_types() -> Result<(), Box<dyn Error>> { - let parsed = XLSParser::parse(Path::new("tests/test_export.xls"), false)?; - let serializable = parsed.into_serializable()?; - - let submission_type_names: HashSet<String> = serializable - .submission_types - .into_iter() - .map(|st| st.name) - .collect(); - - let expected: HashSet<String> = [ - "[a01] Something else", - "[a02] Something else entirely", - "[a03-1] You wont believe this", - "[a03-2] Too hard, gave up", - "[a04-1] Ain‘t gonna try", - "[a04-2] ……", - ] - .iter() - .map(|s| s.to_string()) - .collect(); - - assert_eq!(submission_type_names, expected); - - Ok(()) -} - -#[test] -fn parsed_xls_contains_correct_students() -> Result<(), Box<dyn Error>> { - let parsed = XLSParser::parse(Path::new("tests/test_export.xls"), false)?; - let serializable = parsed.into_serializable()?; - - let students: BTreeSet<StudentSerializable> = serializable - .students - .into_iter() - .map(|mut s| { - s.submissions = BTreeSet::new(); - s - }) - .collect(); - - let students_expected: BTreeSet<StudentSerializable> = [ - StudentSerializable { - fullname: "Student0".to_owned(), - identifier: "20000000".to_owned(), - username: "S20000000".to_owned(), - ..StudentSerializable::default() - }, - StudentSerializable { - fullname: "Student1".to_owned(), - identifier: "20000001".to_owned(), - username: "S20000001".to_owned(), - ..StudentSerializable::default() - }, - StudentSerializable { - fullname: "Student2".to_owned(), - identifier: "20000002".to_owned(), - username: "S20000002".to_owned(), - ..StudentSerializable::default() - }, - ] - .into_iter() - .cloned() - .collect(); - - assert_eq!(students_expected, students); - - Ok(()) -} - -#[test] -fn correct_mapping_is_generated() -> Result<(), Box<dyn Error>> { - use rusty_hektor::anonymizer; - - let parsed = XLSParser::parse(Path::new("tests/test_export.xls"), false)?; - let mut serializable = parsed.into_serializable()?; - - let map = anonymizer::replace_students(&mut serializable.students); - let map_csv = map.into_csv_str(); - - let mut csv_lines = map_csv.split('\n'); - - assert_eq!( - "key;previous identifier;fullname", - csv_lines.next().expect("Mapping file is empty!") - ); - - let student_map_items: Vec<[&str; 3]> = csv_lines - .filter(|line| line.len() != 0) - .map(|line| { - let mut items = line.split(';'); - [ - items.next().expect("Missing key"), - items.next().expect("Missing previous identifier"), - items.next().expect("Missing fullname"), - ] - }) - .collect(); - - let find_student_in_map = |num| { - student_map_items - .iter() - .find(|item| { - item[1] == format!("2000000{}", num) && item[2] == format!("Student{}", num) - }) - .expect(format!("Unable to find Student{} in mapping", num).as_str()) - }; - - let student0 = find_student_in_map(0); - let student1 = find_student_in_map(1); - let student2 = find_student_in_map(2); - - let students = serializable.students; - - let find_student_by_key_in_serializable = |key| { - students - .iter() - .find(|stud| stud.identifier == key) - .expect("No Student0 in serializable data") - }; - - for student in [student0, student1, student2].iter() { - assert_eq!( - 6, - find_student_by_key_in_serializable(student[0]) - .submissions - .len() - ) - } - - let submission_of_student = |stud_key, sub_type_name| { - &find_student_by_key_in_serializable(stud_key) - .submissions - .iter() - .find(|sub| sub.r#type == sub_type_name) - .expect( - format!( - "Unable to find Submission {} for {}", - sub_type_name, stud_key - ) - .as_str(), - ) - .code - }; - - assert_eq!( - "Student0", - submission_of_student(student0[0], "[a04-2] ……") - ); - assert_eq!( - "Student1", - submission_of_student(student1[0], "[a04-1] Ain‘t gonna try") - ); - assert_eq!( - "Student2", - submission_of_student(student2[0], "[a03-2] Too hard, gave up") - ); - - Ok(()) -} diff --git a/tests/test_xml_parser.rs b/tests/test_xml_parser.rs index 3980767..c0d9e7f 100644 --- a/tests/test_xml_parser.rs +++ b/tests/test_xml_parser.rs @@ -2,12 +2,153 @@ use std::error::Error; use std::path::Path; use rusty_hektor::parser::{xml_parser::XMLParser, Parser}; +use std::collections::{HashSet, BTreeSet}; +use rusty_hektor::student::StudentSerializable; #[test] -fn can_parse_zipped_xml_data() -> Result<(), Box<Error>> { +fn can_parse_zipped_xml_data() -> Result<(), Box<dyn Error>> { let parsed = XMLParser::parse(Path::new("tests/test.zip"), false)?; let serializable = parsed.into_serializable()?; assert_eq!(1, serializable.submission_types.len()); assert_eq!(1, serializable.students.len()); Ok(()) } + + +#[test] +fn parsed_xml_contains_correct_submission_types() -> Result<(), Box<dyn Error>> { + let parsed = XMLParser::parse(Path::new("tests/test.zip"), false)?; + let serializable = parsed.into_serializable()?; + + let submission_type_names: HashSet<String> = serializable + .submission_types + .into_iter() + .map(|st| st.name) + .collect(); + + let expected: HashSet<String> = [ + "Eine Bibliothek für Permutationen (I1-ID: l120mlc005h0)", + ] + .iter() + .map(|s| s.to_string()) + .collect(); + + assert_eq!(submission_type_names, expected); + + Ok(()) +} + +#[test] +fn parsed_xls_contains_correct_students() -> Result<(), Box<dyn Error>> { + let parsed = XMLParser::parse(Path::new("tests/test.zip"), false)?; + let serializable = parsed.into_serializable()?; + + let students: BTreeSet<StudentSerializable> = serializable + .students + .into_iter() + .map(|mut s| { + s.submissions = BTreeSet::new(); + s + }) + .collect(); + + let students_expected: BTreeSet<StudentSerializable> = [ + StudentSerializable { + fullname: "Test, User".to_owned(), + identifier: "20000000".to_owned(), + username: "TU20000000".to_owned(), + ..StudentSerializable::default() + }, + ] + .into_iter() + .cloned() + .collect(); + + assert_eq!(students_expected, students); + + Ok(()) +} + +#[test] +fn correct_mapping_is_generated() -> Result<(), Box<dyn Error>> { + use rusty_hektor::anonymizer; + + let parsed = XMLParser::parse(Path::new("tests/test.zip"), false)?; + let mut serializable = parsed.into_serializable()?; + + let map = anonymizer::replace_students(&mut serializable.students); + let map_csv = map.into_csv_str(); + + let mut csv_lines = map_csv.split('\n'); + + assert_eq!( + "key;previous identifier;fullname", + csv_lines.next().expect("Mapping file is empty!") + ); + + let student_map_items: Vec<[&str; 3]> = csv_lines + .filter(|line| line.len() != 0) + .map(|line| { + let mut items = line.split(';'); + [ + items.next().expect("Missing key"), + items.next().expect("Missing previous identifier"), + items.next().expect("Missing fullname"), + ] + }) + .collect(); + + let find_student_in_map = |num| { + student_map_items + .iter() + .find(|item| { + item[1] == format!("2000000{}", num) + }) + .expect(format!("Unable to find Student{} in mapping", num).as_str()) + }; + + let student0 = find_student_in_map(0); + + let students = serializable.students; + + let find_student_by_key_in_serializable = |key| { + students + .iter() + .find(|stud| stud.identifier == key) + .expect("No Student in serializable data") + }; + + for student in [student0, ].iter() { + assert_eq!( + 1, + find_student_by_key_in_serializable(student[0]) + .submissions + .len() + ) + } + + let submission_of_student = |stud_key, sub_type_name| { + &find_student_by_key_in_serializable(stud_key) + .submissions + .iter() + .find(|sub| sub.r#type == sub_type_name) + .expect( + format!( + "Unable to find Submission {} for {}", + sub_type_name, stud_key + ) + .as_str(), + ) + .code + }; + + assert_eq!( + "234;", + submission_of_student( + student0[0], + "Eine Bibliothek für Permutationen (I1-ID: l120mlc005h0)" + ) + ); + + Ok(()) +} -- GitLab