1use 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#[derive(Debug)]
14pub struct TestResult {
15 pub name: String,
17 pub passed: bool,
19 pub duration_ms: u64,
21 pub error_output: Option<String>,
23}
24
25#[derive(Debug, Default)]
27pub struct TestSummary {
28 pub total: usize,
30 pub passed: usize,
32 pub failed: usize,
34 pub file_results: Vec<FileTestResults>,
36}
37
38#[derive(Debug)]
40pub struct FileTestResults {
41 pub path: PathBuf,
43 pub tests: Vec<TestResult>,
45 pub compile_error: Option<String>,
47}
48
49pub struct TestRunner {
51 pub verbose: bool,
53 pub filter: Option<String>,
55 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 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 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 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 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 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 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 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 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 let output = Command::new(&binary_path).output();
234
235 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 let results = self.parse_test_output(&stdout, test_names, compile_time);
249
250 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 let passed = output
292 .lines()
293 .any(|line| line.contains(test_name) && line.contains("... ok"));
294
295 let error_output = if !passed {
296 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, error_output,
310 });
311 }
312
313 results
314 }
315
316 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 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 println!("\n========================================");
366 println!(
367 "Results: {} passed, {} failed",
368 summary.passed, summary.failed
369 );
370
371 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
393fn 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}