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
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

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