seqc/
test_runner.rs

1//! Test runner for Seq test files
2//!
3//! Discovers and executes tests in `test-*.seq` files, reporting results.
4
5use crate::parser::Parser;
6use crate::{CompilerConfig, compile_file_with_config};
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::time::Instant;
11
12/// Result of running a single test
13#[derive(Debug)]
14pub struct TestResult {
15    /// Name of the test function
16    pub name: String,
17    /// Whether the test passed
18    pub passed: bool,
19    /// Duration in milliseconds
20    pub duration_ms: u64,
21    /// Error output if test failed
22    pub error_output: Option<String>,
23}
24
25/// Summary of all test results
26#[derive(Debug, Default)]
27pub struct TestSummary {
28    /// Total tests run
29    pub total: usize,
30    /// Tests passed
31    pub passed: usize,
32    /// Tests failed
33    pub failed: usize,
34    /// Results by file
35    pub file_results: Vec<FileTestResults>,
36}
37
38/// Results for a single test file
39#[derive(Debug)]
40pub struct FileTestResults {
41    /// Path to the test file
42    pub path: PathBuf,
43    /// Individual test results
44    pub tests: Vec<TestResult>,
45    /// Compilation error if file failed to compile
46    pub compile_error: Option<String>,
47}
48
49/// Test runner configuration
50pub struct TestRunner {
51    /// Show verbose output
52    pub verbose: bool,
53    /// Filter pattern for test names
54    pub filter: Option<String>,
55    /// Compiler configuration
56    pub config: CompilerConfig,
57}
58
59impl TestRunner {
60    pub fn new(verbose: bool, filter: Option<String>) -> Self {
61        Self {
62            verbose,
63            filter,
64            config: CompilerConfig::default(),
65        }
66    }
67
68    /// Discover test files in the given paths
69    pub fn discover_test_files(&self, paths: &[PathBuf]) -> Vec<PathBuf> {
70        let mut test_files = Vec::new();
71
72        for path in paths {
73            if path.is_file() {
74                if self.is_test_file(path) {
75                    test_files.push(path.clone());
76                }
77            } else if path.is_dir() {
78                self.discover_in_directory(path, &mut test_files);
79            }
80        }
81
82        test_files.sort();
83        test_files
84    }
85
86    fn is_test_file(&self, path: &Path) -> bool {
87        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
88            name.starts_with("test-") && name.ends_with(".seq")
89        } else {
90            false
91        }
92    }
93
94    fn discover_in_directory(&self, dir: &Path, files: &mut Vec<PathBuf>) {
95        if let Ok(entries) = fs::read_dir(dir) {
96            for entry in entries.flatten() {
97                let path = entry.path();
98                if path.is_file() && self.is_test_file(&path) {
99                    files.push(path);
100                } else if path.is_dir() {
101                    self.discover_in_directory(&path, files);
102                }
103            }
104        }
105    }
106
107    /// Discover test functions in a source file
108    /// Returns (test_names, has_main) - test names and whether file has its own main
109    pub fn discover_test_functions(&self, source: &str) -> Result<(Vec<String>, bool), String> {
110        let mut parser = Parser::new(source);
111        let program = parser.parse()?;
112
113        let has_main = program.words.iter().any(|w| w.name == "main");
114
115        let mut test_names: Vec<String> = program
116            .words
117            .iter()
118            .filter(|w| w.name.starts_with("test-"))
119            .filter(|w| self.matches_filter(&w.name))
120            .map(|w| w.name.clone())
121            .collect();
122
123        test_names.sort();
124        Ok((test_names, has_main))
125    }
126
127    fn matches_filter(&self, name: &str) -> bool {
128        match &self.filter {
129            Some(pattern) => name.contains(pattern),
130            None => true,
131        }
132    }
133
134    /// Run all tests in a file
135    pub fn run_file(&self, path: &Path) -> FileTestResults {
136        let source = match fs::read_to_string(path) {
137            Ok(s) => s,
138            Err(e) => {
139                return FileTestResults {
140                    path: path.to_path_buf(),
141                    tests: vec![],
142                    compile_error: Some(format!("Failed to read file: {}", e)),
143                };
144            }
145        };
146
147        let (test_names, has_main) = match self.discover_test_functions(&source) {
148            Ok(result) => result,
149            Err(e) => {
150                return FileTestResults {
151                    path: path.to_path_buf(),
152                    tests: vec![],
153                    compile_error: Some(format!("Parse error: {}", e)),
154                };
155            }
156        };
157
158        // Skip files that have their own main - they are standalone test suites
159        if has_main {
160            return FileTestResults {
161                path: path.to_path_buf(),
162                tests: vec![],
163                compile_error: None,
164            };
165        }
166
167        if test_names.is_empty() {
168            return FileTestResults {
169                path: path.to_path_buf(),
170                tests: vec![],
171                compile_error: None,
172            };
173        }
174
175        // Compile once and run all tests in the file
176        self.run_all_tests_in_file(path, &source, &test_names)
177    }
178
179    fn run_all_tests_in_file(
180        &self,
181        path: &Path,
182        source: &str,
183        test_names: &[String],
184    ) -> FileTestResults {
185        let start = Instant::now();
186
187        // Generate wrapper main that runs ALL tests in sequence
188        let mut test_calls = String::new();
189        for test_name in test_names {
190            test_calls.push_str(&format!(
191                "  \"{}\" test.init {} test.finish\n",
192                test_name, test_name
193            ));
194        }
195
196        let wrapper = format!(
197            r#"{}
198
199: main ( -- )
200{}  test.has-failures if
201    1 os.exit
202  then
203;
204"#,
205            source, test_calls
206        );
207
208        // Create temp file for the wrapper
209        let temp_dir = std::env::temp_dir();
210        let file_id = sanitize_name(&path.to_string_lossy());
211        let wrapper_path = temp_dir.join(format!("seq_test_{}.seq", file_id));
212        let binary_path = temp_dir.join(format!("seq_test_{}", file_id));
213
214        if let Err(e) = fs::write(&wrapper_path, &wrapper) {
215            return FileTestResults {
216                path: path.to_path_buf(),
217                tests: vec![],
218                compile_error: Some(format!("Failed to write temp file: {}", e)),
219            };
220        }
221
222        // Compile the wrapper (ONE compilation for all tests in file)
223        if let Err(e) = compile_file_with_config(&wrapper_path, &binary_path, false, &self.config) {
224            let _ = fs::remove_file(&wrapper_path);
225            return FileTestResults {
226                path: path.to_path_buf(),
227                tests: vec![],
228                compile_error: Some(format!("Compilation error: {}", e)),
229            };
230        }
231
232        // Run the compiled tests
233        let output = Command::new(&binary_path).output();
234
235        // Clean up temp files
236        let _ = fs::remove_file(&wrapper_path);
237        let _ = fs::remove_file(&binary_path);
238
239        let compile_time = start.elapsed().as_millis() as u64;
240
241        match output {
242            Ok(output) => {
243                let stdout = String::from_utf8_lossy(&output.stdout);
244                let stderr = String::from_utf8_lossy(&output.stderr);
245
246                // Parse output to determine which tests passed/failed
247                // Output format: "test-name ... ok" or "test-name ... FAILED"
248                let results = self.parse_test_output(&stdout, test_names, compile_time);
249
250                // If we couldn't parse results but process failed, mark all as failed
251                if results.iter().all(|r| r.passed) && !output.status.success() {
252                    return FileTestResults {
253                        path: path.to_path_buf(),
254                        tests: test_names
255                            .iter()
256                            .map(|name| TestResult {
257                                name: name.clone(),
258                                passed: false,
259                                duration_ms: 0,
260                                error_output: Some(format!("{}{}", stderr, stdout)),
261                            })
262                            .collect(),
263                        compile_error: None,
264                    };
265                }
266
267                FileTestResults {
268                    path: path.to_path_buf(),
269                    tests: results,
270                    compile_error: None,
271                }
272            }
273            Err(e) => FileTestResults {
274                path: path.to_path_buf(),
275                tests: vec![],
276                compile_error: Some(format!("Failed to run tests: {}", e)),
277            },
278        }
279    }
280
281    fn parse_test_output(
282        &self,
283        output: &str,
284        test_names: &[String],
285        _compile_time: u64,
286    ) -> Vec<TestResult> {
287        let mut results = Vec::new();
288
289        for test_name in test_names {
290            // Look for "test-name ... ok" or "test-name ... FAILED: message"
291            let passed = output
292                .lines()
293                .any(|line| line.contains(test_name) && line.contains("... ok"));
294
295            let error_output = if !passed {
296                // Find failure message
297                output
298                    .lines()
299                    .find(|line| line.contains(test_name) && line.contains("FAILED"))
300                    .map(|s| s.to_string())
301            } else {
302                None
303            };
304
305            results.push(TestResult {
306                name: test_name.clone(),
307                passed,
308                duration_ms: 0, // Individual timing not available in batch mode
309                error_output,
310            });
311        }
312
313        results
314    }
315
316    /// Run tests and return summary
317    pub fn run(&self, paths: &[PathBuf]) -> TestSummary {
318        let test_files = self.discover_test_files(paths);
319        let mut summary = TestSummary::default();
320
321        for path in test_files {
322            let file_results = self.run_file(&path);
323
324            for test in &file_results.tests {
325                summary.total += 1;
326                if test.passed {
327                    summary.passed += 1;
328                } else {
329                    summary.failed += 1;
330                }
331            }
332
333            summary.file_results.push(file_results);
334        }
335
336        summary
337    }
338
339    /// Print test results
340    pub fn print_results(&self, summary: &TestSummary) {
341        for file_result in &summary.file_results {
342            if let Some(ref error) = file_result.compile_error {
343                eprintln!("\nFailed to process {}:", file_result.path.display());
344                eprintln!("  {}", error);
345                continue;
346            }
347
348            if file_result.tests.is_empty() {
349                continue;
350            }
351
352            println!("\nRunning tests in {}...", file_result.path.display());
353
354            for test in &file_result.tests {
355                let status = if test.passed { "ok" } else { "FAILED" };
356                if self.verbose {
357                    println!("  {} ... {} ({}ms)", test.name, status, test.duration_ms);
358                } else {
359                    println!("  {} ... {}", test.name, status);
360                }
361            }
362        }
363
364        // Print summary
365        println!("\n========================================");
366        println!(
367            "Results: {} passed, {} failed",
368            summary.passed, summary.failed
369        );
370
371        // Print failures in detail
372        let failures: Vec<_> = summary
373            .file_results
374            .iter()
375            .flat_map(|fr| fr.tests.iter().filter(|t| !t.passed).map(|t| (&fr.path, t)))
376            .collect();
377
378        if !failures.is_empty() {
379            println!("\nFAILURES:\n");
380            for (path, test) in failures {
381                println!("{}::{}", path.display(), test.name);
382                if let Some(ref error) = test.error_output {
383                    for line in error.lines() {
384                        println!("  {}", line);
385                    }
386                }
387                println!();
388            }
389        }
390    }
391}
392
393/// Sanitize a test name for use as a filename
394fn sanitize_name(name: &str) -> String {
395    name.chars()
396        .map(|c| if c.is_alphanumeric() { c } else { '_' })
397        .collect()
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    #[test]
405    fn test_is_test_file() {
406        let runner = TestRunner::new(false, None);
407        assert!(runner.is_test_file(Path::new("test-foo.seq")));
408        assert!(runner.is_test_file(Path::new("test-arithmetic.seq")));
409        assert!(!runner.is_test_file(Path::new("foo.seq")));
410        assert!(!runner.is_test_file(Path::new("test-foo.txt")));
411        assert!(!runner.is_test_file(Path::new("my-test.seq")));
412    }
413
414    #[test]
415    fn test_discover_test_functions() {
416        let runner = TestRunner::new(false, None);
417        let source = r#"
418: test-addition ( -- )
419  2 3 add 5 test.assert-eq
420;
421
422: test-subtraction ( -- )
423  5 3 subtract 2 test.assert-eq
424;
425
426: helper ( -- Int )
427  42
428;
429"#;
430        let (tests, has_main) = runner.discover_test_functions(source).unwrap();
431        assert_eq!(tests.len(), 2);
432        assert!(tests.contains(&"test-addition".to_string()));
433        assert!(tests.contains(&"test-subtraction".to_string()));
434        assert!(!tests.contains(&"helper".to_string()));
435        assert!(!has_main);
436    }
437
438    #[test]
439    fn test_discover_with_main() {
440        let runner = TestRunner::new(false, None);
441        let source = r#"
442: test-foo ( -- ) ;
443: main ( -- ) ;
444"#;
445        let (tests, has_main) = runner.discover_test_functions(source).unwrap();
446        assert_eq!(tests.len(), 1);
447        assert!(has_main);
448    }
449
450    #[test]
451    fn test_filter() {
452        let runner = TestRunner::new(false, Some("add".to_string()));
453        let source = r#"
454: test-addition ( -- ) ;
455: test-subtraction ( -- ) ;
456"#;
457        let (tests, _) = runner.discover_test_functions(source).unwrap();
458        assert_eq!(tests.len(), 1);
459        assert!(tests.contains(&"test-addition".to_string()));
460    }
461
462    #[test]
463    fn test_sanitize_name() {
464        assert_eq!(sanitize_name("test-foo"), "test_foo");
465        assert_eq!(sanitize_name("test-foo-bar"), "test_foo_bar");
466    }
467}