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 compile_failures: usize,
36 pub file_results: Vec<FileTestResults>,
38}
39
40impl TestSummary {
41 pub fn has_failures(&self) -> bool {
43 self.failed > 0 || self.compile_failures > 0
44 }
45}
46
47#[derive(Debug)]
49pub struct FileTestResults {
50 pub path: PathBuf,
52 pub tests: Vec<TestResult>,
54 pub compile_error: Option<String>,
56}
57
58pub struct TestRunner {
60 pub verbose: bool,
62 pub filter: Option<String>,
64 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 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 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 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 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 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 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 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 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 let output = Command::new(&binary_path).output();
243
244 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 let results = self.parse_test_output(&stdout, test_names, compile_time);
258
259 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 let passed = output
301 .lines()
302 .any(|line| line.contains(test_name) && line.contains("... ok"));
303
304 let error_output = if !passed {
305 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, error_output,
319 });
320 }
321
322 results
323 }
324
325 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 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 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 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 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 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
434fn 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}