tmc_langs_csharp/
plugin.rs

1//! An implementation of LanguagePlugin for C#.
2
3use crate::{CSharpError, cs_test_result::CSTestResult, policy::CSharpStudentFilePolicy};
4use std::{
5    collections::{HashMap, HashSet},
6    env,
7    ffi::{OsStr, OsString},
8    io::{BufReader, Cursor, Read, Seek},
9    ops::ControlFlow::{Break, Continue},
10    path::{Path, PathBuf},
11    time::Duration,
12};
13use tmc_langs_framework::{
14    Archive, CommandError, ExerciseDesc, Language, LanguagePlugin, RunResult, RunStatus,
15    StyleValidationResult, StyleValidationStrategy, TestDesc, TestResult, TmcCommand, TmcError,
16    nom::{IResult, Parser, bytes, character, combinator, sequence},
17    nom_language::error::VerboseError,
18};
19use tmc_langs_util::{deserialize, file_util, parse_util, path_util};
20use walkdir::WalkDir;
21use zip::ZipArchive;
22
23const TMC_CSHARP_RUNNER: &[u8] = include_bytes!("../deps/tmc-csharp-runner-2.0.zip");
24const TMC_CSHARP_RUNNER_VERSION: &str = "2.0";
25
26#[derive(Default)]
27pub struct CSharpPlugin {}
28
29impl CSharpPlugin {
30    pub fn new() -> Self {
31        Self {}
32    }
33
34    /// Extracts the bundled tmc-csharp-runner to the given path.
35    fn extract_runner_to_dir(target: &Path) -> Result<(), CSharpError> {
36        log::debug!("extracting C# runner to {}", target.display());
37
38        let mut zip = ZipArchive::new(Cursor::new(TMC_CSHARP_RUNNER))?;
39        for i in 0..zip.len() {
40            let file = zip.by_index(i)?;
41            if file.is_file() {
42                let target_file_path = target.join(Path::new(file.name()));
43                if let Some(parent) = target_file_path.parent() {
44                    file_util::create_dir_all(parent)?;
45                }
46                let bytes = file_util::read_reader(file)?;
47                file_util::write_to_file(bytes, target_file_path)?;
48            }
49        }
50        Ok(())
51    }
52
53    /// Returns the directory of the TMC C# runner, writing it to the cache dir if it doesn't exist there yet.
54    ///
55    /// NOTE: May cause issues if called concurrently.
56    /// TODO: Currently this is checked every time when necessary. It could also be done in the constructor, but then it would be done in cases where unnecessary (when checking code style, for example)
57    fn get_or_init_runner_dir() -> Result<PathBuf, CSharpError> {
58        log::debug!("getting C# runner dir");
59        match dirs::cache_dir() {
60            Some(cache_dir) => {
61                let runner_dir = cache_dir.join("tmc").join("tmc-csharp-runner");
62                let version_path = runner_dir.join("VERSION");
63
64                let needs_update = if version_path.exists() {
65                    let version = file_util::read_file_to_string(&version_path)?;
66                    version != TMC_CSHARP_RUNNER_VERSION
67                } else {
68                    true
69                };
70
71                if needs_update {
72                    log::debug!("updating the cached C# runner");
73                    if runner_dir.exists() {
74                        // clear the directory if it exists
75                        file_util::remove_dir_all(&runner_dir)?;
76                    }
77                    Self::extract_runner_to_dir(&runner_dir)?;
78                    file_util::write_to_file(TMC_CSHARP_RUNNER_VERSION.as_bytes(), version_path)?;
79                }
80                Ok(runner_dir)
81            }
82            None => Err(CSharpError::CacheDir),
83        }
84    }
85
86    /// Returns the path to the TMC C# runner in the cache. If TMC_CSHARP_BOOTSTRAP_PATH is set, it is returned instead.
87    fn get_bootstrap_path() -> Result<PathBuf, CSharpError> {
88        if let Ok(var) = env::var("TMC_CSHARP_BOOTSTRAP_PATH") {
89            log::debug!("using bootstrap path TMC_CSHARP_BOOTSTRAP_PATH={var}");
90            Ok(PathBuf::from(var))
91        } else {
92            let runner_path = Self::get_or_init_runner_dir()?;
93            let bootstrap_path = runner_path.join("TestMyCode.CSharp.Bootstrap.dll");
94            if bootstrap_path.exists() {
95                log::debug!("found boostrap dll at {}", bootstrap_path.display());
96                Ok(bootstrap_path)
97            } else {
98                Err(CSharpError::MissingBootstrapDll(bootstrap_path))
99            }
100        }
101    }
102
103    /// Parses the test results JSON file at the path argument.
104    fn parse_test_results(
105        test_results_path: &Path,
106    ) -> Result<(RunStatus, Vec<TestResult>), CSharpError> {
107        log::debug!("parsing C# test results");
108        let test_results = file_util::open_file(test_results_path)?;
109        let test_results: Vec<CSTestResult> = deserialize::json_from_reader(test_results)
110            .map_err(|e| CSharpError::ParseTestResults(test_results_path.to_path_buf(), e))?;
111
112        let mut status = RunStatus::Passed;
113        let mut failed_points = HashSet::new();
114        for test_result in &test_results {
115            if !test_result.passed {
116                status = RunStatus::TestsFailed;
117                failed_points.extend(test_result.points.iter().cloned());
118            }
119        }
120
121        // convert the parsed C# test results into TMC test results
122        let test_results = test_results
123            .into_iter()
124            .map(|t| t.into_test_result(&failed_points))
125            .collect();
126        Ok((status, test_results))
127    }
128}
129
130/// Project directory:
131/// Contains a src directory which contains a .cs or .csproj file (which may be inside a subdirectory).
132impl LanguagePlugin for CSharpPlugin {
133    const PLUGIN_NAME: &'static str = "csharp";
134    const DEFAULT_SANDBOX_IMAGE: &'static str = "eu.gcr.io/moocfi-public/tmc-sandbox-csharp:latest";
135    const LINE_COMMENT: &'static str = "//";
136    const BLOCK_COMMENT: Option<(&'static str, &'static str)> = Some(("/*", "*/"));
137    type StudentFilePolicy = CSharpStudentFilePolicy;
138
139    fn is_exercise_type_correct(path: &Path) -> bool {
140        WalkDir::new(path.join("src"))
141            .max_depth(2)
142            .into_iter()
143            .filter_map(|e| e.ok())
144            .any(|e| {
145                let ext = e.path().extension();
146                ext == Some(&OsString::from("cs")) || ext == Some(&OsString::from("csproj"))
147            })
148    }
149
150    fn find_project_dir_in_archive<R: Read + Seek>(
151        archive: &mut Archive<R>,
152    ) -> Result<PathBuf, TmcError> {
153        let mut iter = archive.iter()?;
154        let project_dir = loop {
155            let next = iter.with_next(|entry| {
156                let file_path = entry.path()?;
157                let ext = file_path.extension();
158
159                if entry.is_file()
160                    && (ext == Some(OsStr::new("cs")) || ext == Some(OsStr::new("csproj")))
161                    && !file_path.components().any(|c| c.as_os_str() == "__MACOSX")
162                {
163                    if let Some(parent) = file_path.parent() {
164                        if let Some(src_parent) = path_util::get_parent_of_named(parent, "src") {
165                            return Ok(Break(Some(src_parent)));
166                        }
167                        if let Some(parent) = parent.parent() {
168                            if let Some(src_parent) = path_util::get_parent_of_named(parent, "src")
169                            {
170                                return Ok(Break(Some(src_parent)));
171                            }
172                        }
173                    }
174                }
175                Ok(Continue(()))
176            });
177            match next? {
178                Continue(_) => continue,
179                Break(project_dir) => break project_dir,
180            }
181        };
182        match project_dir {
183            Some(project_dir) => Ok(project_dir),
184            None => Err(TmcError::NoProjectDirInArchive),
185        }
186    }
187
188    /// Runs --generate-points-file and parses the generated .tmc_available_points.json.
189    fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError> {
190        // clean old points file
191        let exercise_desc_json_path = path.join(".tmc_available_points.json");
192        if exercise_desc_json_path.exists() {
193            file_util::remove_file(&exercise_desc_json_path)?;
194        }
195
196        // run command
197        let bootstrap_path = Self::get_bootstrap_path()?;
198        let _output = TmcCommand::piped("dotnet")
199            .with(|e| {
200                e.cwd(path)
201                    .arg(bootstrap_path)
202                    .arg("--generate-points-file")
203            })
204            .output_checked()?;
205
206        // TODO: the command above can fail silently in some edge cases
207        // parse result file
208        let exercise_desc_json = file_util::open_file(&exercise_desc_json_path)?;
209        let json: HashMap<String, Vec<String>> =
210            deserialize::json_from_reader(BufReader::new(exercise_desc_json))
211                .map_err(|e| CSharpError::ParseExerciseDesc(exercise_desc_json_path, e))?;
212
213        let mut tests = vec![];
214        for (key, value) in json {
215            tests.push(TestDesc::new(key, value));
216        }
217
218        Ok(ExerciseDesc {
219            name: exercise_name,
220            tests,
221        })
222    }
223
224    /// Runs --run-tests and parses the resulting .tmc_test_results.json.
225    fn run_tests_with_timeout(
226        &self,
227        path: &Path,
228        timeout: Option<Duration>,
229    ) -> Result<RunResult, TmcError> {
230        // clean old file
231        let test_results_path = path.join(".tmc_test_results.json");
232        if test_results_path.exists() {
233            file_util::remove_file(&test_results_path)?;
234        }
235
236        // run command
237        let bootstrap_path = Self::get_bootstrap_path()?;
238        let command = TmcCommand::piped("dotnet")
239            .with(|e| e.cwd(path).arg(bootstrap_path).arg("--run-tests"));
240        let output = if let Some(timeout) = timeout {
241            command.output_with_timeout(timeout)
242        } else {
243            command.output()
244        };
245
246        match output {
247            Ok(output) => {
248                let stdout = String::from_utf8_lossy(&output.stdout);
249                let stderr = String::from_utf8_lossy(&output.stderr);
250                if !output.status.success() {
251                    log::warn!("stdout: {stdout}");
252                    log::error!("stderr: {stderr}");
253                    let mut logs = HashMap::new();
254                    logs.insert("stdout".to_string(), stdout.into_owned());
255                    logs.insert("stderr".to_string(), stderr.into_owned());
256                    return Ok(RunResult {
257                        status: RunStatus::CompileFailed,
258                        test_results: vec![],
259                        logs,
260                    });
261                }
262
263                log::trace!("stdout: {stdout}");
264                log::debug!("stderr: {stderr}");
265
266                if !test_results_path.exists() {
267                    return Err(CSharpError::MissingTestResults {
268                        path: test_results_path,
269                        stdout: stdout.into_owned(),
270                        stderr: stderr.into_owned(),
271                    }
272                    .into());
273                }
274                let (status, test_results) = Self::parse_test_results(&test_results_path)?;
275                file_util::remove_file(&test_results_path)?;
276
277                let mut logs = HashMap::new();
278                logs.insert("stdout".to_string(), stdout.into_owned());
279                logs.insert("stderr".to_string(), stderr.into_owned());
280                Ok(RunResult {
281                    status,
282                    test_results,
283                    logs,
284                })
285            }
286            Err(TmcError::Command(CommandError::TimeOut { stdout, stderr, .. })) => {
287                let mut logs = HashMap::new();
288                logs.insert("stdout".to_string(), stdout);
289                logs.insert("stderr".to_string(), stderr);
290                Ok(RunResult {
291                    status: RunStatus::TestsFailed,
292                    test_results: vec![TestResult {
293                    name: "Timeout test".to_string(),
294                    successful: false,
295                    points: vec![],
296                    message:
297                        "Tests timed out.\nMake sure you don't have an infinite loop in your code."
298                            .to_string(),
299                    exception: vec![],
300                }],
301                    logs,
302                })
303            }
304            Err(error) => Err(error),
305        }
306    }
307
308    /// No-op for C#.
309    fn check_code_style(
310        &self,
311        _path: &Path,
312        _locale: Language,
313    ) -> Result<Option<StyleValidationResult>, TmcError> {
314        Ok(Some(StyleValidationResult {
315            strategy: StyleValidationStrategy::Disabled,
316            validation_errors: None,
317        }))
318    }
319
320    /// Removes all bin and obj sub-directories.
321    fn clean(&self, path: &Path) -> Result<(), TmcError> {
322        // clean old result file
323        let test_results_path = path.join(".tmc_test_results.json");
324        if test_results_path.exists() {
325            log::info!("removing old test results file");
326            file_util::remove_file(&test_results_path)?;
327        }
328
329        // delete bin and obj directories
330        for entry in WalkDir::new(path).into_iter().filter_map(|e| e.ok()) {
331            let file_name = entry.path().file_name();
332            if entry.path().is_dir()
333                && (file_name == Some(&OsString::from("bin"))
334                    || file_name == Some(&OsString::from("obj")))
335            {
336                log::info!("cleaning directory {}", entry.path().display());
337                file_util::remove_dir_all(entry.path())?;
338            }
339        }
340        Ok(())
341    }
342
343    fn get_default_student_file_paths() -> Vec<PathBuf> {
344        vec![PathBuf::from("src")]
345    }
346
347    fn get_default_exercise_file_paths() -> Vec<PathBuf> {
348        vec![PathBuf::from("test")]
349    }
350
351    fn points_parser(i: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
352        combinator::map(
353            sequence::delimited(
354                (
355                    character::complete::char('['),
356                    character::complete::multispace0,
357                    bytes::complete::tag_no_case("points"),
358                    character::complete::multispace0,
359                    character::complete::char('('),
360                    character::complete::multispace0,
361                ),
362                parse_util::comma_separated_strings,
363                (
364                    character::complete::multispace0,
365                    character::complete::char(')'),
366                    character::complete::multispace0,
367                    character::complete::char(']'),
368                ),
369            ),
370            // splits each point by whitespace
371            |points| {
372                points
373                    .into_iter()
374                    .flat_map(|p| p.split_whitespace())
375                    .collect()
376            },
377        )
378        .parse(i)
379    }
380}
381
382#[cfg(test)]
383#[allow(clippy::unwrap_used)]
384mod test {
385    use super::*;
386    use once_cell::sync::Lazy;
387    use std::sync::{Mutex, Once};
388    use tempfile::TempDir;
389    use zip::write::SimpleFileOptions;
390
391    static INIT_RUNNER: Once = Once::new();
392    // running the runner in parallel seems to sometimes make tests run for an excessively long time
393    static MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
394
395    fn init() {
396        use log::*;
397        use simple_logger::*;
398        let _ = SimpleLogger::new().with_level(LevelFilter::Debug).init();
399        INIT_RUNNER.call_once(|| {
400            let _ = CSharpPlugin::get_or_init_runner_dir().unwrap();
401        });
402    }
403
404    fn file_to(
405        target_dir: impl AsRef<std::path::Path>,
406        target_relative: impl AsRef<std::path::Path>,
407        contents: impl AsRef<[u8]>,
408    ) -> PathBuf {
409        let target = target_dir.as_ref().join(target_relative);
410        if let Some(parent) = target.parent() {
411            std::fs::create_dir_all(parent).unwrap();
412        }
413        std::fs::write(&target, contents.as_ref()).unwrap();
414        target
415    }
416
417    fn dir_to_zip(source_dir: impl AsRef<std::path::Path>) -> Vec<u8> {
418        use std::io::Write;
419
420        let mut target = vec![];
421        let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut target));
422
423        for entry in walkdir::WalkDir::new(&source_dir)
424            .min_depth(1)
425            .sort_by(|a, b| a.path().cmp(b.path()))
426        {
427            let entry = entry.unwrap();
428            let rela = entry
429                .path()
430                .strip_prefix(&source_dir)
431                .unwrap()
432                .to_str()
433                .unwrap();
434            if entry.path().is_dir() {
435                zip.add_directory(rela, SimpleFileOptions::default())
436                    .unwrap();
437            } else if entry.path().is_file() {
438                zip.start_file(rela, SimpleFileOptions::default()).unwrap();
439                let bytes = std::fs::read(entry.path()).unwrap();
440                zip.write_all(&bytes).unwrap();
441            }
442        }
443
444        zip.finish().unwrap();
445        target
446    }
447
448    fn dir_to_temp(source_dir: impl AsRef<std::path::Path>) -> tempfile::TempDir {
449        let temp = tempfile::TempDir::new().unwrap();
450        for entry in walkdir::WalkDir::new(&source_dir).min_depth(1) {
451            let entry = entry.unwrap();
452            let rela = entry.path().strip_prefix(&source_dir).unwrap();
453            let target = temp.path().join(rela);
454            if entry.path().is_dir() {
455                std::fs::create_dir(target).unwrap();
456            } else if entry.path().is_file() {
457                std::fs::copy(entry.path(), target).unwrap();
458            }
459        }
460        temp
461    }
462
463    #[test]
464    fn extracts_runner_to_dir() {
465        init();
466
467        let temp = tempfile::TempDir::new().unwrap();
468        CSharpPlugin::extract_runner_to_dir(temp.path()).unwrap();
469        assert!(temp.path().join("TestMyCode.CSharp.Bootstrap.dll").exists());
470    }
471
472    #[test]
473    fn gets_bootstrap_path() {
474        init();
475
476        let path = CSharpPlugin::get_bootstrap_path().unwrap();
477        assert!(
478            path.to_string_lossy()
479                .contains("TestMyCode.CSharp.Bootstrap.dll")
480        );
481    }
482
483    #[test]
484    fn parses_test_results() {
485        init();
486
487        let temp = tempfile::TempDir::new().unwrap();
488        let json = file_to(
489            &temp,
490            ".tmc_test_results.json",
491            r#"
492[
493    {
494        "Name": "n1",
495        "Passed": true,
496        "Message": "m1",
497        "Points": ["1", "2"],
498        "ErrorStackTrace": []
499    },
500    {
501        "Name": "n2",
502        "Passed": false,
503        "Message": "m2",
504        "Points": [],
505        "ErrorStackTrace": ["err"]
506    }
507]
508"#,
509        );
510        let (status, test_results) = CSharpPlugin::parse_test_results(&json).unwrap();
511        assert_eq!(status, RunStatus::TestsFailed);
512        assert_eq!(test_results.len(), 2);
513    }
514
515    #[test]
516    fn exercise_type_is_correct() {
517        init();
518
519        let temp = TempDir::new().unwrap();
520        file_to(&temp, "src/dir/sample.csproj", "");
521        assert!(CSharpPlugin::is_exercise_type_correct(temp.path()));
522    }
523
524    #[test]
525    fn exercise_type_is_incorrect() {
526        init();
527
528        let temp = TempDir::new().unwrap();
529        file_to(&temp, "src/dir/dir/dir/sample.csproj", "");
530        file_to(&temp, "sample.csproj", "");
531        assert!(!CSharpPlugin::is_exercise_type_correct(temp.path()));
532    }
533
534    #[test]
535    fn finds_project_dir_in_zip() {
536        init();
537
538        let temp = TempDir::new().unwrap();
539        file_to(&temp, "dir1/dir2/dir3/src/dir4/sample.csproj", "");
540        let bytes = dir_to_zip(&temp);
541        let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
542        let dir = CSharpPlugin::find_project_dir_in_archive(&mut zip).unwrap();
543        assert_eq!(dir, Path::new("dir1/dir2/dir3"))
544    }
545
546    #[test]
547    fn finds_project_dir_in_zip_cs() {
548        init();
549
550        let temp = TempDir::new().unwrap();
551        file_to(&temp, "dir1/dir2/dir3/src/dir4/sample.cs", "");
552        let bytes = dir_to_zip(&temp);
553        let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
554        let dir = CSharpPlugin::find_project_dir_in_archive(&mut zip).unwrap();
555        assert_eq!(dir, Path::new("dir1/dir2/dir3"))
556    }
557
558    #[test]
559    fn no_project_dir_in_zip() {
560        init();
561
562        let temp = TempDir::new().unwrap();
563        file_to(&temp, "dir1/dir2/dir3/not src/directly in src.csproj", "");
564        file_to(&temp, "dir1/dir2/dir3/src/__MACOSX/under macosx.csproj", "");
565        file_to(&temp, "dir1/__MACOSX/dir3/src/dir/under macosx.csproj", "");
566        let bytes = dir_to_zip(&temp);
567        let mut zip = Archive::zip(std::io::Cursor::new(bytes)).unwrap();
568        let dir = CSharpPlugin::find_project_dir_in_archive(&mut zip);
569        assert!(dir.is_err())
570    }
571
572    #[test]
573    fn scans_exercise() {
574        init();
575        let _lock = MUTEX.lock().unwrap();
576
577        let temp = dir_to_temp("tests/data/passing-exercise");
578        let plugin = CSharpPlugin::new();
579        let scan = plugin
580            .scan_exercise(temp.path(), "name".to_string())
581            .unwrap();
582        assert_eq!(scan.name, "name");
583        assert_eq!(scan.tests.len(), 2);
584    }
585
586    #[test]
587    fn runs_tests_passing() {
588        init();
589        let _lock = MUTEX.lock().unwrap();
590
591        let temp = dir_to_temp("tests/data/passing-exercise");
592        let plugin = CSharpPlugin::new();
593        let res = plugin.run_tests(temp.path()).unwrap();
594        assert_eq!(res.status, RunStatus::Passed);
595        assert_eq!(res.test_results.len(), 2);
596        for tr in res.test_results {
597            assert!(tr.successful);
598        }
599        assert!(res.logs.get("stdout").unwrap().is_empty());
600        assert!(res.logs.get("stderr").unwrap().is_empty());
601    }
602
603    #[test]
604    fn runs_tests_failing() {
605        init();
606        let _lock = MUTEX.lock().unwrap();
607
608        let temp = dir_to_temp("tests/data/failing-exercise");
609        let plugin = CSharpPlugin::new();
610        let res = plugin.run_tests(temp.path()).unwrap();
611        assert_eq!(res.status, RunStatus::TestsFailed);
612        assert_eq!(res.test_results.len(), 1);
613        let test_result = &res.test_results[0];
614        assert!(!test_result.successful);
615        assert!(test_result.points.is_empty());
616        assert!(test_result.message.contains("Expected: False"));
617        assert!(!test_result.exception.is_empty());
618        assert!(res.logs.get("stdout").unwrap().is_empty());
619        assert!(res.logs.get("stderr").unwrap().is_empty());
620    }
621
622    #[test]
623    fn runs_tests_compile_err() {
624        init();
625        let _lock = MUTEX.lock().unwrap();
626
627        let temp = dir_to_temp("tests/data/non-compiling-exercise");
628        let plugin = CSharpPlugin::new();
629        let res = plugin.run_tests(temp.path()).unwrap();
630        assert_eq!(res.status, RunStatus::CompileFailed);
631        assert!(!res.logs.is_empty());
632        log::debug!("{:?}", res.logs.get("stdout"));
633        assert!(
634            res.logs
635                .get("stdout")
636                .unwrap()
637                .contains("This is a compile error")
638        );
639    }
640
641    #[test]
642    fn runs_tests_timeout() {
643        init();
644        let _lock = MUTEX.lock().unwrap();
645
646        let temp = dir_to_temp("tests/data/passing-exercise");
647        let plugin = CSharpPlugin::new();
648        let res = plugin
649            .run_tests_with_timeout(temp.path(), Some(std::time::Duration::from_nanos(1)))
650            .unwrap();
651        assert_eq!(res.status, RunStatus::TestsFailed);
652    }
653
654    #[test]
655    fn cleans() {
656        init();
657        let _lock = MUTEX.lock().unwrap();
658
659        let temp = dir_to_temp("tests/data/passing-exercise");
660        let plugin = CSharpPlugin::new();
661        let bin_path = temp.path().join("src").join("PassingSample").join("bin");
662        let obj_path_test = temp
663            .path()
664            .join("test")
665            .join("PassingSampleTests")
666            .join("obj");
667        assert!(!bin_path.exists());
668        assert!(!obj_path_test.exists());
669        plugin.run_tests(temp.path()).unwrap();
670        assert!(bin_path.exists());
671        assert!(obj_path_test.exists());
672        plugin.clean(temp.path()).unwrap();
673        assert!(!bin_path.exists());
674        assert!(!obj_path_test.exists());
675    }
676
677    #[test]
678    fn parses_points() {
679        let res = CSharpPlugin::points_parser("asd");
680        assert!(res.is_err());
681
682        let res = CSharpPlugin::points_parser("[Points(\"1\")]").unwrap();
683        assert_eq!(res.1, &["1"]);
684
685        let res = CSharpPlugin::points_parser("[  pOiNtS  (  \"  1  \"  )  ]").unwrap();
686        assert_eq!(res.1, &["1"]);
687
688        let res = CSharpPlugin::points_parser("[Points(\"1\", \"2\"  ,  \"3\")]").unwrap();
689        assert_eq!(res.1, &["1", "2", "3"]);
690    }
691
692    #[test]
693    fn doesnt_give_points_unless_all_relevant_exercises_pass() {
694        init();
695        let _lock = MUTEX.lock().unwrap();
696
697        let temp = dir_to_temp("tests/data/partially-passing");
698        let plugin = CSharpPlugin::new();
699        let results = plugin.run_tests(temp.path()).unwrap();
700        assert_eq!(results.status, RunStatus::TestsFailed);
701        let mut got_point = false;
702        for test in results.test_results {
703            got_point = got_point || test.points.contains(&"1.2".to_string());
704            assert!(!test.points.contains(&"1".to_string()));
705            assert!(!test.points.contains(&"1.1".to_string()));
706        }
707    }
708}