tmc_langs_notests/
plugin.rs

1//! The language plugin for no-tests projects.
2
3use crate::NoTestsStudentFilePolicy;
4use std::{
5    collections::HashMap,
6    io::{Read, Seek},
7    ops::ControlFlow::{Break, Continue},
8    path::{Path, PathBuf},
9    time::Duration,
10};
11use tmc_langs_framework::{
12    Archive, ExerciseDesc, LanguagePlugin, RunResult, RunStatus, StudentFilePolicy, TestDesc,
13    TestResult, TmcError, TmcProjectYml,
14    nom::{self, IResult},
15    nom_language::error::VerboseError,
16};
17use tmc_langs_util::{deserialize, path_util};
18
19#[derive(Default)]
20pub struct NoTestsPlugin {}
21
22impl NoTestsPlugin {
23    pub fn new() -> Self {
24        Self {}
25    }
26
27    /// Convenience function to get a list of the points for the project at path.
28    fn get_points(path: &Path) -> Vec<String> {
29        <Self as LanguagePlugin>::StudentFilePolicy::new(path)
30            .ok()
31            .as_ref()
32            .map(|p| p.get_project_config())
33            .and_then(|c| c.no_tests.as_ref().map(|n| n.points.clone()))
34            .unwrap_or_default()
35    }
36}
37
38/// Project directory:
39/// Contains a .tmcproject.yml file that has `no-tests` set to `true`.
40impl LanguagePlugin for NoTestsPlugin {
41    const PLUGIN_NAME: &'static str = "No-Tests";
42    const DEFAULT_SANDBOX_IMAGE: &'static str = "eu.gcr.io/moocfi-public/tmc-sandbox-python:latest"; // doesn't really matter, just use Python image
43    const LINE_COMMENT: &'static str = "//";
44    const BLOCK_COMMENT: Option<(&'static str, &'static str)> = None;
45    type StudentFilePolicy = NoTestsStudentFilePolicy;
46
47    fn scan_exercise(&self, path: &Path, exercise_name: String) -> Result<ExerciseDesc, TmcError> {
48        let test_name = format!("{exercise_name}Test");
49        Ok(ExerciseDesc {
50            name: exercise_name,
51            tests: vec![TestDesc {
52                name: test_name,
53                points: Self::get_points(path),
54            }],
55        })
56    }
57
58    fn run_tests_with_timeout(
59        &self,
60        path: &Path,
61        _timeout: Option<Duration>,
62    ) -> Result<RunResult, TmcError> {
63        Ok(RunResult {
64            status: RunStatus::Passed,
65            test_results: vec![TestResult {
66                name: "Default test".to_string(),
67                successful: true,
68                points: Self::get_points(path),
69                message: "".to_string(),
70                exception: vec![],
71            }],
72            logs: HashMap::new(),
73        })
74    }
75
76    fn is_exercise_type_correct(path: &Path) -> bool {
77        Self::StudentFilePolicy::new(path)
78            .ok()
79            .as_ref()
80            .map(|p| p.get_project_config())
81            .and_then(|c| c.no_tests.as_ref())
82            .map(|nt| nt.flag)
83            .unwrap_or(false)
84    }
85
86    fn find_project_dir_in_archive<R: Read + Seek>(
87        archive: &mut Archive<R>,
88    ) -> Result<PathBuf, TmcError> {
89        let mut iter = archive.iter()?;
90
91        let project_dir = loop {
92            let next = iter.with_next(|file| {
93                let file_path = file.path()?;
94
95                if file.is_file() {
96                    // check for .tmcproject.yml
97                    if let Some(parent) =
98                        path_util::get_parent_of_named(&file_path, ".tmcproject.yml")
99                    {
100                        let tmc_project_yml: TmcProjectYml = deserialize::yaml_from_reader(file)
101                            .map_err(|e| TmcError::YamlDeserialize(file_path, e))?;
102                        // check no-tests
103                        if tmc_project_yml
104                            .no_tests
105                            .map(|nt| nt.flag)
106                            .unwrap_or_default()
107                        {
108                            return Ok(Break(Some(parent)));
109                        }
110                    }
111                }
112                Ok(Continue(()))
113            });
114            match next? {
115                Continue(_) => continue,
116                Break(project_dir) => break project_dir,
117            }
118        };
119        if let Some(project_dir) = project_dir {
120            Ok(project_dir)
121        } else {
122            Err(TmcError::NoProjectDirInArchive)
123        }
124    }
125
126    fn clean(&self, _path: &Path) -> Result<(), TmcError> {
127        Ok(())
128    }
129
130    fn get_default_student_file_paths() -> Vec<PathBuf> {
131        vec![PathBuf::from("src")]
132    }
133
134    fn get_default_exercise_file_paths() -> Vec<PathBuf> {
135        vec![PathBuf::from("test")]
136    }
137
138    fn points_parser(_: &str) -> IResult<&str, Vec<&str>, VerboseError<&str>> {
139        // never parses anything
140        Err(nom::Err::Error(VerboseError { errors: vec![] }))
141    }
142}
143
144#[cfg(test)]
145#[allow(clippy::unwrap_used)]
146mod test {
147    use super::*;
148
149    fn init() {
150        use log::*;
151        use simple_logger::*;
152        let _ = SimpleLogger::new().with_level(LevelFilter::Debug).init();
153    }
154
155    fn file_to(
156        target_dir: impl AsRef<std::path::Path>,
157        target_relative: impl AsRef<std::path::Path>,
158        contents: impl AsRef<[u8]>,
159    ) -> PathBuf {
160        let target = target_dir.as_ref().join(target_relative);
161        if let Some(parent) = target.parent() {
162            std::fs::create_dir_all(parent).unwrap();
163        }
164        std::fs::write(&target, contents.as_ref()).unwrap();
165        target
166    }
167
168    #[test]
169    fn gets_points() {
170        init();
171
172        let temp_dir = tempfile::tempdir().unwrap();
173        file_to(
174            &temp_dir,
175            ".tmcproject.yml",
176            r#"
177no-tests: 
178    points:
179        - point1
180        - point2
181        - 3
182        - 4
183"#,
184        );
185
186        let points = NoTestsPlugin::get_points(temp_dir.path());
187        assert_eq!(points.len(), 4)
188    }
189
190    #[test]
191    fn scans_exercise() {
192        init();
193
194        let plugin = NoTestsPlugin::new();
195        let _exercise_desc = plugin
196            .scan_exercise(Path::new("/nonexistent path"), "ex".to_string())
197            .unwrap();
198    }
199
200    #[test]
201    fn runs_tests_ignores_timeout() {
202        init();
203
204        let plugin = NoTestsPlugin::new();
205        let run_result = plugin
206            .run_tests_with_timeout(
207                Path::new("/nonexistent"),
208                Some(std::time::Duration::from_nanos(1)),
209            )
210            .unwrap();
211        assert_eq!(run_result.status, RunStatus::Passed);
212    }
213
214    #[test]
215    fn exercise_type_is_correct() {
216        init();
217
218        let temp_dir = tempfile::tempdir().unwrap();
219        file_to(
220            &temp_dir,
221            ".tmcproject.yml",
222            r#"
223no-tests: 
224    points: [point1]
225"#,
226        );
227        assert!(NoTestsPlugin::is_exercise_type_correct(temp_dir.path()));
228
229        let temp_dir = tempfile::tempdir().unwrap();
230        file_to(
231            &temp_dir,
232            ".tmcproject.yml",
233            r#"
234no-tests: true
235"#,
236        );
237        assert!(NoTestsPlugin::is_exercise_type_correct(temp_dir.path()));
238    }
239
240    #[test]
241    fn exercise_type_is_not_correct() {
242        init();
243
244        let temp_dir = tempfile::tempdir().unwrap();
245        assert!(!NoTestsPlugin::is_exercise_type_correct(temp_dir.path()));
246
247        let temp_dir = tempfile::tempdir().unwrap();
248        file_to(&temp_dir, ".tmcproject.yml", r#""#);
249        assert!(!NoTestsPlugin::is_exercise_type_correct(temp_dir.path()));
250
251        let temp_dir = tempfile::tempdir().unwrap();
252        file_to(
253            &temp_dir,
254            ".tmcproject.yml",
255            r#"
256no-tests: false
257"#,
258        );
259        assert!(!NoTestsPlugin::is_exercise_type_correct(temp_dir.path()));
260    }
261
262    #[test]
263    fn parses_empty() {
264        init();
265
266        let temp = tempfile::tempdir().unwrap();
267        file_to(&temp, "test/.keep", r#""#);
268
269        let points = NoTestsPlugin::get_available_points(temp.path()).unwrap();
270        assert!(points.is_empty());
271
272        let temp = tempfile::tempdir().unwrap();
273        file_to(
274            &temp,
275            "test/.keep",
276            r#"
277"#,
278        );
279
280        let points = NoTestsPlugin::get_available_points(temp.path()).unwrap();
281        assert!(points.is_empty());
282    }
283}