Skip to main content

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