//! A bundle of data that rubrics are graded against, and is submitted for review
// std uses
use std::collections::HashMap;
// external uses
use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize};
// internal uses
use crate::results_file::AsCsv;
use crate::rubric::Rubric;
use crate::server;
/// A type alias to `HashMap<String, String>`
///
/// This is the data type that all criteria accept,
/// and how data is stored in a submission
pub type TestData = HashMap<String, String>;
/// A macro to easily create a [`TestData`](crate::submission::TestData)
/// struct, which is really just an alias to `HashMap<String, String>`.
///
/// ## Example
/// ```rust
/// # extern crate rubric;
/// use rubric::{TestData, data};
///
/// // The long way
/// let mut map = TestData::new();
/// map.insert(String::from("key"), String::from("value"));
///
/// // the macro way
/// let data = data! { "key" => "value" };
/// assert_eq!(map, data);
/// ```
#[macro_export]
macro_rules! data (
{ $($key:expr => $value:expr),+ } => {
{
let mut m = ::std::collections::HashMap::new();
$(
m.insert(String::from($key), String::from($value));
)+
m
}
};
);
/// A submission is a bundle of data that represents
/// one student's submission. They will do some sort of work
/// for a lab, then run a rust script that builds some criteria,
/// runs those criteria with some data from the student, and submits
/// a Submission to a central webserver where the instructor can
/// collect the graded submissions.
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct Submission {
/// A local timestamp when the submission was created
pub time: DateTime<Local>,
/// Numerical grade for the submission.
/// Each criterion will add to this grade if it passes.
pub grade: i16,
/// Extra data attached to the submission.
/// Leave it empty if you don't need it
pub data: TestData,
/// The criteria (name) that this submission passed
pub passed: Vec<String>,
/// The citeria (name) that this submission failed
pub failed: Vec<String>
}
impl Submission {
/// Creates a new submission.
///
/// ## Example
/// ```rust
/// use rubric::Submission;
///
/// // You probably want it to be mutable so
/// // you can attach data and change the grade
/// let mut sub = Submission::new();
///
/// assert_eq!(sub.grade, 0);
/// assert_eq!(sub.data.len(), 0);
/// ```
pub fn new() -> Submission {
Submission {
time: Local::now(),
grade: 0,
data: TestData::new(),
passed: Vec::new(),
failed: Vec::new()
}
}
/// Attaches data to a submission
///
/// The data must be a [`TestData`](crate::submission::TestData).
/// You may want to use the [`data!`](../macro.data.html) macro to make it
/// easier to establish your data.
///
/// You may be interested in [`Submission::from_data`](crate::submission::Submission::from_data).
///
/// ## Example
/// ```rust
/// # use rubric::data;
/// # use rubric::Submission;
/// #
/// let data = data! {
/// "key" => "value",
/// "key2" => "value2"
/// };
///
/// let mut sub = Submission::new();
/// sub.use_data(data);
///
/// assert_eq!(sub.data["key"], "value");
/// assert_eq!(sub.data["key2"], "value2");
/// ```
pub fn use_data(&mut self, data: TestData) {
self.data = data;
}
/// Creates a new submission and attaches data to it in one step
///
/// ## Example
/// ```rust
/// # use rubric::{Submission, data};
///
/// let sub = Submission::from_data(data! {
/// "name" => "luke i guess",
/// "id" => "1234"
/// });
///
/// assert_eq!(sub.data["id"], "1234");
/// ```
pub fn from_data(data: TestData) -> Self {
let mut sub = Submission::new();
sub.use_data(data);
sub
}
/// Marks a criterion as passed. Provide the name of the criterion.
///
/// This struct does not include an actual [`Criterion`](crate::criterion::Criterion)
/// struct in it's `passed` and `failed` fields, because it's impossible to
/// serialize a `Criterion`. `Submission`s must be serializable. Instead, only the
/// name and message of the criterion are stored on the submission
///
/// ## Example
/// ```rust
/// # use rubric::Submission;
/// let mut sub = Submission::new();
/// sub.pass("Some criterion name");
///
/// assert!(sub.passed.contains(&"Some criterion name".to_string()));
/// ```
pub fn pass<C: AsRef<str>>(&mut self, criterion: C) {
self.passed.push(criterion.as_ref().to_string());
}
/// Same as [`pass`](crate::submission::Submission::pass), but adds to the `failed` vector
pub fn fail<C: AsRef<str>>(&mut self, criterion: C) {
self.failed.push(criterion.as_ref().to_string());
}
/// Tests a submission against a list of criterion
pub fn grade_against(&mut self, rubric: &mut Rubric) {
for crit in &mut rubric.sorted().into_iter() {
crit.test_with_data(&self.data);
if crit.status.unwrap() {
self.grade += crit.worth;
self.pass(format!("{}: {}", crit.name, crit.success_message()));
} else {
self.fail(format!("{}: {}", crit.name, crit.failure_message()));
}
}
}
/// Spins up a webserver to accept submission.
///
/// Accepted submissions will be written to a [`ResultsFile`](crate::results_file::ResultsFile).
/// The web server will run on the provided port.
///
/// The results file will be placed in the directory you execute the code in,
/// and be called `submissions.csv`.
///
/// The best way to submit a submission to the server that this function starts is
/// to call [`post_json`](crate::helpers::web::post_json) from the web helpers module and
/// pass it the url that this server is accessible on, and a submission. It will convert
/// it to json for you.
///
/// Support for custom results file locations is coming...
/// ```no_run
/// use rubric::Submission;
/// Submission::server(8080);
/// ```
pub fn server(port: u16) {
server::run(port);
}
}
impl AsCsv for TestData {
/// Returns the test data, serialized to a csv string. It will be
/// sorted alphabetically by key.
fn as_csv(&self) -> String {
let values: Vec<&String> = self.values().collect();
let mut owned_values: Vec<String> = values.iter().map(|&k| k.replace(",", ";").to_owned() ).collect();
owned_values.sort_by(|a,b| a.cmp(&b) );
return owned_values.join(",");
}
/// Returns the filename that the [`ResultsFile`](crate::results_file::ResultsFile)
/// uses as its output
///
/// This probably shouldn't get used for test data, as it will be written as part
/// of a submission, not on it's own.
fn filename(&self) -> String {
String::from("submission_data.csv")
}
/// Returns a header to write to a csv file. This should match the fields in `as_csv` above.
fn header(&self) -> String {
let keys: Vec<&String> = self.keys().collect();
let mut owned_keys: Vec<String> = keys.iter().map(|&k| k.to_owned() ).collect();
owned_keys.sort_by(|a,b| a.cmp(&b) );
return format!("{}", owned_keys.join(","));
}
}
impl AsCsv for Submission {
/// Returns the submission's values in csv format. The `TestData` atttached will be
/// sorted alphabetically by key.
fn as_csv(&self) -> String {
format!(
"{},{},{},{},{}",
self.time.to_rfc3339(),
self.grade,
self.passed.join(";"),
self.failed.join(";"),
self.data.as_csv()
)
}
/// Returns the filename to use when writing submissions to disk
fn filename(&self) -> String {
String::from("submissions.csv")
}
/// Returns a header of all the fields, matching the data in `as_csv`
fn header(&self) -> String {
format!("time,grade,passed,failed,{}", self.data.header())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{data, yaml, attach};
#[test]
fn test_new_submission() {
let sub = Submission::new();
assert!(sub.data.len() == 0);
}
#[test]
fn test_submission_use_data() {
let data = data! {
"key" => "value"
};
let mut sub = Submission::new();
sub.use_data(data);
assert!(sub.data.len() == 1);
assert_eq!(sub.data["key"], "value");
let sub2 = Submission::from_data(data! {
"key" => "value"
});
assert_eq!(sub2.data["key"], "value");
}
#[test]
fn test_submission_as_csv() {
let sub = Submission::from_data(data! { "a" => "v", "b" => "v2" });
// TestData keys are sorted alphabetically when converting to csv
assert!((&sub).as_csv().contains("v,v2"));
// Submission with no data, passes, or failures
let sub2 = Submission::new();
let expected = "0,,,";
assert!((&sub2).as_csv().contains(expected));
}
#[test]
fn test_serialize_deserialize_json() {
let mut sub = Submission::from_data(data! { "k2" => "v2", "k" => "v" });
sub.pass("something");
sub.fail("something");
// Assert the
assert!(serde_json::to_string(&sub).unwrap().contains(r#""k2":"v2""#));
let data = r#"{"time":"2020-05-01T22:23:21.180875-05:00","grade":0,"passed":["something"],"failed":["something"],"data":{"k2":"v2","k":"v"}}"#;
let built_sub: Submission = serde_json::from_str(data).unwrap();
assert_eq!(built_sub.grade, sub.grade);
}
#[test]
fn test_grade_against_rubric() {
let yaml = yaml!("../test_data/test_rubric.yml").unwrap();
let mut rubric = Rubric::from_yaml(yaml).unwrap();
let test = |_: &TestData| true;
attach! {
rubric,
"first-crit" => test
};
let mut sub = Submission::new();
sub.grade_against(&mut rubric);
assert_eq!(sub.grade, 50);
}
#[test]
fn test_test_data_as_csv() {
let d = data! {
"b2" => "value2",
"a1" => "value1"
};
let expected_header = "a1,b2";
let expected_values = "value1,value2";
let expected_filename = "submission_data.csv";
assert_eq!(d.header(), expected_header);
assert_eq!(d.as_csv(), expected_values);
assert_eq!(d.filename(), expected_filename);
}
#[test]
fn test_as_csv_replaces_commas() {
let sub = Submission::from_data(data! {
"key" => "value with, comma"
});
assert!(sub.as_csv().contains("value with; comma"));
}
}