1use std::collections::BTreeSet;
7use std::path::{Path, PathBuf};
8
9use runmat_builtins::{
10 BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
11 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
12 CellArray, ObjectInstance, Value,
13};
14use runmat_hir::RUNTESTS_BUILTIN_NAME;
15use runmat_macros::runtime_builtin;
16
17use crate::builtins::common::fs::{expand_user_path, path_to_string};
18use crate::builtins::common::path_search::{
19 file_candidates, find_file_with_extensions, path_is_directory, path_is_file,
20};
21use crate::builtins::common::spec::{
22 BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
23 ReductionNaN, ResidencyPolicy, ShapeRequirements,
24};
25use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
26
27const RUNTESTS_INPUTS: [BuiltinParamDescriptor; 2] = [
28 BuiltinParamDescriptor {
29 name: "tests",
30 ty: BuiltinParamType::Any,
31 arity: BuiltinParamArity::Optional,
32 default: Some("current folder"),
33 description: "Test file, folder, function name, string array, or cell array of targets.",
34 },
35 BuiltinParamDescriptor {
36 name: "Name,Value",
37 ty: BuiltinParamType::Any,
38 arity: BuiltinParamArity::Variadic,
39 default: None,
40 description: "Common options such as IncludeSubfolders, BaseFolder, Name, ProcedureName, and UseParallel.",
41 },
42];
43
44const RUNTESTS_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
45 name: "results",
46 ty: BuiltinParamType::Any,
47 arity: BuiltinParamArity::Optional,
48 default: None,
49 description: "Scalar TestResult object or cell row of TestResult objects.",
50}];
51
52const RUNTESTS_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
53 BuiltinSignatureDescriptor {
54 label: "results = runtests",
55 inputs: &[],
56 outputs: &RUNTESTS_OUTPUT,
57 },
58 BuiltinSignatureDescriptor {
59 label: "results = runtests(tests, Name, Value, ...)",
60 inputs: &RUNTESTS_INPUTS,
61 outputs: &RUNTESTS_OUTPUT,
62 },
63];
64
65pub const RUNTESTS_ERROR_REQUIRES_VM: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
66 code: "RM.RUNTESTS.REQUIRES_VM",
67 identifier: Some("RunMat:runtests:RequiresVm"),
68 when: "`runtests` is dispatched outside an active VM workspace frame.",
69 message: "runtests: requires VM workspace context",
70};
71
72pub const RUNTESTS_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
73 code: "RM.RUNTESTS.INVALID_INPUT",
74 identifier: Some("RunMat:runtests:InvalidInput"),
75 when: "A target or option value has an unsupported type or value.",
76 message: "runtests: invalid input",
77};
78
79pub const RUNTESTS_ERROR_UNSUPPORTED_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
80 code: "RM.RUNTESTS.UNSUPPORTED_OPTION",
81 identifier: Some("RunMat:runtests:UnsupportedOption"),
82 when: "A documented option requires infrastructure not implemented by this slice.",
83 message: "runtests: unsupported option",
84};
85
86pub const RUNTESTS_ERROR_TARGET_NOT_FOUND: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
87 code: "RM.RUNTESTS.TARGET_NOT_FOUND",
88 identifier: Some("RunMat:runtests:TargetNotFound"),
89 when: "A requested test target cannot be resolved to a file or folder.",
90 message: "runtests: test target not found",
91};
92
93pub const RUNTESTS_ERROR_FILE_READ: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
94 code: "RM.RUNTESTS.FILE_READ",
95 identifier: Some("RunMat:runtests:FileReadFailed"),
96 when: "A discovered test file cannot be read as source text.",
97 message: "runtests: failed to read test file",
98};
99
100pub const RUNTESTS_ERROR_WORKSPACE_STATE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
101 code: "RM.RUNTESTS.WORKSPACE_STATE",
102 identifier: Some("RunMat:runtests:WorkspaceStateFailed"),
103 when: "The VM cannot isolate or restore the caller workspace around a test case.",
104 message: "runtests: workspace isolation failed",
105};
106
107pub const RUNTESTS_ERRORS: [BuiltinErrorDescriptor; 6] = [
108 RUNTESTS_ERROR_REQUIRES_VM,
109 RUNTESTS_ERROR_INVALID_INPUT,
110 RUNTESTS_ERROR_UNSUPPORTED_OPTION,
111 RUNTESTS_ERROR_TARGET_NOT_FOUND,
112 RUNTESTS_ERROR_FILE_READ,
113 RUNTESTS_ERROR_WORKSPACE_STATE,
114];
115
116pub const RUNTESTS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
117 signatures: &RUNTESTS_SIGNATURES,
118 output_mode: BuiltinOutputMode::Fixed,
119 completion_policy: BuiltinCompletionPolicy::Public,
120 errors: &RUNTESTS_ERRORS,
121};
122
123#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::diagnostics::runtests")]
124pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
125 name: "runtests",
126 op_kind: GpuOpKind::Custom("testing"),
127 supported_precisions: &[],
128 broadcast: BroadcastSemantics::None,
129 provider_hooks: &[],
130 constant_strategy: ConstantStrategy::InlineLiteral,
131 residency: ResidencyPolicy::GatherImmediately,
132 nan_mode: ReductionNaN::Include,
133 two_pass_threshold: None,
134 workgroup_size: None,
135 accepts_nan_mode: false,
136 notes: "Test discovery and execution are host control-flow operations. Test bodies may call GPU-capable builtins normally, but runtests itself has no device kernel.",
137};
138
139#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::diagnostics::runtests")]
140pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
141 name: "runtests",
142 shape: ShapeRequirements::Any,
143 constant_strategy: ConstantStrategy::InlineLiteral,
144 elementwise: None,
145 reduction: None,
146 emits_nan: false,
147 notes: "Test execution is a VM and filesystem boundary and is excluded from fusion.",
148};
149
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct RunTestCase {
152 pub name: String,
153 pub source_path: PathBuf,
154 pub display_name: String,
155 pub source: String,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct RunTestsPlan {
160 pub cases: Vec<RunTestCase>,
161}
162
163#[derive(Debug, Clone)]
164pub struct RunTestOutcome {
165 pub name: String,
166 pub source_path: PathBuf,
167 pub passed: bool,
168 pub duration_seconds: f64,
169 pub details: String,
170}
171
172#[derive(Debug, Default)]
173struct RunTestsOptions {
174 include_subfolders: bool,
175 targets: Vec<String>,
176 base_folders: Vec<String>,
177 filters: Vec<String>,
178}
179
180#[runtime_builtin(
181 name = "runtests",
182 category = "diagnostics",
183 summary = "Discover and run MATLAB-style test files.",
184 keywords = "test,unit testing,runtests,diagnostics,developer tools",
185 descriptor(self::RUNTESTS_DESCRIPTOR),
186 builtin_path = "crate::builtins::diagnostics::runtests"
187)]
188pub async fn runtests_builtin(_args: Vec<Value>) -> BuiltinResult<Value> {
189 requires_vm_workspace_context()
190}
191
192pub fn requires_vm_workspace_context() -> BuiltinResult<Value> {
193 Err(runtests_error(&RUNTESTS_ERROR_REQUIRES_VM))
194}
195
196pub async fn resolve_runtests_plan(args: Vec<Value>) -> BuiltinResult<RunTestsPlan> {
197 let gathered = gather_values(args).await?;
198 let options = parse_options(gathered)?;
199 let mut paths = BTreeSet::new();
200 let targets = if options.targets.is_empty() {
201 if !options.base_folders.is_empty() {
202 options.base_folders.clone()
203 } else {
204 vec![path_to_string(&runmat_filesystem::current_dir().map_err(
205 |err| runtests_error_detail(&RUNTESTS_ERROR_TARGET_NOT_FOUND, err.to_string()),
206 )?)]
207 }
208 } else {
209 options.targets.clone()
210 };
211
212 let base_folders = if options.base_folders.is_empty() || options.targets.is_empty() {
213 vec![None]
214 } else {
215 options
216 .base_folders
217 .iter()
218 .map(|folder| Some(folder.as_str()))
219 .collect()
220 };
221
222 for target in targets {
223 for base_folder in &base_folders {
224 for path in resolve_target(&target, *base_folder, options.include_subfolders).await? {
225 paths.insert(path);
226 }
227 }
228 }
229
230 let mut cases = Vec::new();
231 for path in paths {
232 let source = runmat_filesystem::read_to_string_async(&path)
233 .await
234 .map_err(|err| {
235 runtests_error_detail(
236 &RUNTESTS_ERROR_FILE_READ,
237 format!("{} ({err})", path.display()),
238 )
239 })?;
240 let display_path = runmat_filesystem::canonicalize_async(&path)
241 .await
242 .unwrap_or_else(|_| path.clone());
243 let file_name = test_name_for_path(&display_path);
244 let function_tests = function_test_names(&source);
245 if function_tests.is_empty() {
246 if !matches_filters(&file_name, &options.filters) {
247 continue;
248 }
249 cases.push(RunTestCase {
250 name: file_name,
251 source_path: display_path.clone(),
252 display_name: path_to_string(&display_path),
253 source,
254 });
255 } else {
256 for function_name in function_tests {
257 let name = format!("{file_name}/{function_name}");
258 if !matches_filters(&name, &options.filters)
259 && !matches_filters(&function_name, &options.filters)
260 {
261 continue;
262 }
263 cases.push(RunTestCase {
264 name,
265 source_path: display_path.clone(),
266 display_name: path_to_string(&display_path),
267 source: format!("{source}\n{function_name}();\n"),
268 });
269 }
270 }
271 }
272
273 Ok(RunTestsPlan { cases })
274}
275
276pub fn runtests_result_value(outcomes: Vec<RunTestOutcome>) -> BuiltinResult<Value> {
277 let mut values = outcomes
278 .into_iter()
279 .map(test_result_object)
280 .collect::<BuiltinResult<Vec<_>>>()?;
281 if values.len() == 1 {
282 Ok(values.remove(0))
283 } else {
284 let len = values.len();
285 CellArray::new(values, 1, len)
286 .map(Value::Cell)
287 .map_err(|err| runtests_error_detail(&RUNTESTS_ERROR_INVALID_INPUT, err))
288 }
289}
290
291pub fn workspace_state_error(detail: impl AsRef<str>) -> RuntimeError {
292 runtests_error_detail(&RUNTESTS_ERROR_WORKSPACE_STATE, detail)
293}
294
295fn test_result_object(outcome: RunTestOutcome) -> BuiltinResult<Value> {
296 let mut obj = ObjectInstance::new("matlab.unittest.TestResult".to_string());
297 obj.properties
298 .insert("Name".to_string(), Value::String(outcome.name));
299 obj.properties.insert(
300 "TestFile".to_string(),
301 Value::String(path_to_string(&outcome.source_path)),
302 );
303 obj.properties
304 .insert("Passed".to_string(), Value::Bool(outcome.passed));
305 obj.properties
306 .insert("Failed".to_string(), Value::Bool(!outcome.passed));
307 obj.properties
308 .insert("Incomplete".to_string(), Value::Bool(false));
309 obj.properties.insert(
310 "Duration".to_string(),
311 Value::Num(outcome.duration_seconds.max(0.0)),
312 );
313 obj.properties
314 .insert("Details".to_string(), Value::String(outcome.details));
315 Ok(Value::Object(obj))
316}
317
318async fn gather_values(args: Vec<Value>) -> BuiltinResult<Vec<Value>> {
319 let mut out = Vec::with_capacity(args.len());
320 for arg in args {
321 out.push(gather_if_needed_async(&arg).await.map_err(runtests_flow)?);
322 }
323 Ok(out)
324}
325
326fn parse_options(args: Vec<Value>) -> BuiltinResult<RunTestsOptions> {
327 let mut options = RunTestsOptions::default();
328 let mut idx = 0usize;
329
330 if let Some(first) = args.first() {
331 if !is_option_name(first) {
332 options.targets.extend(value_to_strings(first)?);
333 idx = 1;
334 }
335 }
336
337 while idx < args.len() {
338 if idx + 1 >= args.len() {
339 return Err(runtests_error_detail(
340 &RUNTESTS_ERROR_INVALID_INPUT,
341 "name-value options must appear in pairs",
342 ));
343 }
344 let name = value_to_string_scalar(&args[idx])?.to_ascii_lowercase();
345 let value = &args[idx + 1];
346 match normalize_option_name(&name).as_str() {
347 "includesubfolders" => options.include_subfolders = value_to_bool(value)?,
348 "useparallel" => {
349 if value_to_bool(value)? {
350 return Err(runtests_error_detail(
351 &RUNTESTS_ERROR_UNSUPPORTED_OPTION,
352 "UseParallel=true is deferred to the parallel execution effort",
353 ));
354 }
355 }
356 "basefolder" => options.base_folders.extend(value_to_strings(value)?),
357 "name" | "procedurename" => options.filters.extend(value_to_strings(value)?),
358 "outputdetail" | "logginglevel" => {
359 let _ = value_to_string_scalar(value)?;
360 }
361 "tag" => {
362 let tags = value_to_strings(value)?;
363 if !tags.iter().all(|tag| tag.is_empty()) {
364 return Err(runtests_error_detail(
365 &RUNTESTS_ERROR_UNSUPPORTED_OPTION,
366 "tag filtering requires matlab.unittest metadata support",
367 ));
368 }
369 }
370 "coverage" => {
371 return Err(runtests_error_detail(
372 &RUNTESTS_ERROR_UNSUPPORTED_OPTION,
373 "coverage collection is not part of the current runtests slice",
374 ));
375 }
376 other => {
377 return Err(runtests_error_detail(
378 &RUNTESTS_ERROR_INVALID_INPUT,
379 format!("unknown option '{other}'"),
380 ));
381 }
382 }
383 idx += 2;
384 }
385
386 Ok(options)
387}
388
389fn normalize_option_name(name: &str) -> String {
390 name.chars()
391 .filter(|ch| !ch.is_ascii_whitespace() && *ch != '_' && *ch != '-')
392 .flat_map(char::to_lowercase)
393 .collect()
394}
395
396fn is_option_name(value: &Value) -> bool {
397 let Ok(text) = value_to_string_scalar(value) else {
398 return false;
399 };
400 matches!(
401 normalize_option_name(&text).as_str(),
402 "includesubfolders"
403 | "useparallel"
404 | "basefolder"
405 | "name"
406 | "procedurename"
407 | "outputdetail"
408 | "logginglevel"
409 | "tag"
410 | "coverage"
411 )
412}
413
414fn value_to_string_scalar(value: &Value) -> BuiltinResult<String> {
415 match value {
416 Value::String(text) => Ok(text.clone()),
417 Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
418 Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
419 other => Err(runtests_error_detail(
420 &RUNTESTS_ERROR_INVALID_INPUT,
421 format!("expected a string scalar or character row, got {other:?}"),
422 )),
423 }
424}
425
426fn value_to_strings(value: &Value) -> BuiltinResult<Vec<String>> {
427 match value {
428 Value::String(text) => Ok(vec![text.clone()]),
429 Value::CharArray(array) if array.rows == 1 => Ok(vec![array.data.iter().collect()]),
430 Value::StringArray(array) => Ok(array.data.clone()),
431 Value::Cell(cell) => cell.data.iter().map(value_to_string_scalar).collect(),
432 other => Err(runtests_error_detail(
433 &RUNTESTS_ERROR_INVALID_INPUT,
434 format!("expected a string, string array, or cell array of strings, got {other:?}"),
435 )),
436 }
437}
438
439fn value_to_bool(value: &Value) -> BuiltinResult<bool> {
440 match value {
441 Value::Bool(v) => Ok(*v),
442 Value::Num(v) if *v == 0.0 || *v == 1.0 => Ok(*v != 0.0),
443 Value::Int(v) => Ok(v.to_f64() != 0.0),
444 Value::LogicalArray(array) if array.data.len() == 1 => Ok(array.data[0] != 0),
445 other => Err(runtests_error_detail(
446 &RUNTESTS_ERROR_INVALID_INPUT,
447 format!("expected a logical scalar, got {other:?}"),
448 )),
449 }
450}
451
452async fn resolve_target(
453 target: &str,
454 base_folder: Option<&str>,
455 include_subfolders: bool,
456) -> BuiltinResult<Vec<PathBuf>> {
457 let expanded = expand_user_path(target, RUNTESTS_BUILTIN_NAME)
458 .map_err(|err| runtests_error_detail(&RUNTESTS_ERROR_TARGET_NOT_FOUND, err))?;
459 let direct = target_path_in_base(&expanded, base_folder)?;
460 if path_is_directory(&direct).await {
461 return discover_test_files(&direct, include_subfolders).await;
462 }
463 if path_is_file(&direct).await {
464 return Ok(vec![direct]);
465 }
466 if base_folder.is_none() {
467 if let Some(path) = find_file_with_extensions(&expanded, &[".m"], RUNTESTS_BUILTIN_NAME)
468 .await
469 .map_err(|err| runtests_error_detail(&RUNTESTS_ERROR_TARGET_NOT_FOUND, err))?
470 {
471 return Ok(vec![path]);
472 }
473
474 for candidate in file_candidates(&expanded, &[".m"], RUNTESTS_BUILTIN_NAME)
475 .map_err(|err| runtests_error_detail(&RUNTESTS_ERROR_TARGET_NOT_FOUND, err))?
476 {
477 if path_is_directory(&candidate).await {
478 return discover_test_files(&candidate, include_subfolders).await;
479 }
480 }
481 } else if direct.extension().is_none() {
482 let candidate = direct.with_extension("m");
483 if path_is_file(&candidate).await {
484 return Ok(vec![candidate]);
485 }
486 }
487
488 Err(runtests_error_detail(
489 &RUNTESTS_ERROR_TARGET_NOT_FOUND,
490 format!("'{target}'"),
491 ))
492}
493
494fn target_path_in_base(target: &str, base_folder: Option<&str>) -> BuiltinResult<PathBuf> {
495 let target = PathBuf::from(target);
496 let Some(base_folder) = base_folder else {
497 return Ok(target);
498 };
499 if target.is_absolute() {
500 return Ok(target);
501 }
502 let expanded = expand_user_path(base_folder, RUNTESTS_BUILTIN_NAME)
503 .map_err(|err| runtests_error_detail(&RUNTESTS_ERROR_TARGET_NOT_FOUND, err))?;
504 Ok(PathBuf::from(expanded).join(target))
505}
506
507async fn discover_test_files(dir: &Path, include_subfolders: bool) -> BuiltinResult<Vec<PathBuf>> {
508 let mut out = Vec::new();
509 let mut stack = vec![dir.to_path_buf()];
510 while let Some(current) = stack.pop() {
511 let entries = runmat_filesystem::read_dir_async(¤t)
512 .await
513 .map_err(|err| {
514 runtests_error_detail(
515 &RUNTESTS_ERROR_TARGET_NOT_FOUND,
516 format!("{} ({err})", current.display()),
517 )
518 })?;
519 for entry in entries {
520 let path = entry.path().to_path_buf();
521 if entry.is_dir() {
522 if include_subfolders {
523 stack.push(path);
524 }
525 continue;
526 }
527 if is_test_file(&path) {
528 out.push(path);
529 }
530 }
531 }
532 out.sort();
533 Ok(out)
534}
535
536fn is_test_file(path: &Path) -> bool {
537 if !path
538 .extension()
539 .and_then(|ext| ext.to_str())
540 .is_some_and(|ext| ext.eq_ignore_ascii_case("m"))
541 {
542 return false;
543 }
544 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
545 return false;
546 };
547 let lower = stem.to_ascii_lowercase();
548 lower.starts_with("test") || lower.ends_with("test")
549}
550
551fn test_name_for_path(path: &Path) -> String {
552 path.file_stem()
553 .and_then(|stem| stem.to_str())
554 .filter(|stem| !stem.is_empty())
555 .unwrap_or("unnamed")
556 .to_string()
557}
558
559fn matches_filters(name: &str, filters: &[String]) -> bool {
560 filters.is_empty() || filters.iter().any(|filter| name.contains(filter))
561}
562
563fn function_test_names(source: &str) -> Vec<String> {
564 let mut names = Vec::new();
565 for line in source.lines() {
566 let trimmed = line.trim_start();
567 let lowered = trimmed.to_ascii_lowercase();
568 if !lowered.starts_with("function") {
569 continue;
570 }
571 let rest = trimmed["function".len()..].trim_start();
572 let after_outputs = rest
573 .split_once('=')
574 .map(|(_, rhs)| rhs.trim_start())
575 .unwrap_or(rest);
576 let name = after_outputs
577 .chars()
578 .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
579 .collect::<String>();
580 if name.is_empty() {
581 continue;
582 }
583 let lower_name = name.to_ascii_lowercase();
584 if lower_name.starts_with("test") || lower_name.ends_with("test") {
585 names.push(name);
586 }
587 }
588 names.sort();
589 names.dedup();
590 names
591}
592
593fn runtests_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
594 runtests_error_detail(error, "")
595}
596
597fn runtests_error_detail(
598 error: &'static BuiltinErrorDescriptor,
599 detail: impl AsRef<str>,
600) -> RuntimeError {
601 let detail = detail.as_ref();
602 let message = if detail.is_empty() {
603 error.message.to_string()
604 } else {
605 format!("{}: {detail}", error.message)
606 };
607 let mut builder = build_runtime_error(message).with_builtin(RUNTESTS_BUILTIN_NAME);
608 if let Some(identifier) = error.identifier {
609 builder = builder.with_identifier(identifier);
610 }
611 builder.build()
612}
613
614fn runtests_flow(err: RuntimeError) -> RuntimeError {
615 let identifier = err.identifier().map(str::to_string);
616 let mut builder = build_runtime_error(err.message().to_string())
617 .with_builtin(RUNTESTS_BUILTIN_NAME)
618 .with_source(err);
619 if let Some(identifier) = identifier {
620 builder = builder.with_identifier(identifier);
621 }
622 builder.build()
623}
624
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use runmat_builtins::{CharArray, LogicalArray, StringArray};
629
630 #[test]
631 fn parse_accepts_target_and_include_subfolders() {
632 let opts = parse_options(vec![
633 Value::String("tests".to_string()),
634 Value::String("IncludeSubfolders".to_string()),
635 Value::Bool(true),
636 ])
637 .expect("parse options");
638 assert_eq!(opts.targets, vec!["tests"]);
639 assert!(opts.include_subfolders);
640 }
641
642 #[test]
643 fn parse_rejects_parallel_execution() {
644 let err = parse_options(vec![
645 Value::String("UseParallel".to_string()),
646 Value::LogicalArray(LogicalArray::new(vec![1], vec![1, 1]).unwrap()),
647 ])
648 .unwrap_err();
649 assert_eq!(
650 err.identifier().map(str::to_string),
651 Some("RunMat:runtests:UnsupportedOption".to_string())
652 );
653 }
654
655 #[test]
656 fn string_collection_accepts_cell_targets() {
657 let cell = CellArray::new(
658 vec![
659 Value::CharArray(CharArray::new_row("testOne")),
660 Value::String("testTwo".to_string()),
661 ],
662 1,
663 2,
664 )
665 .unwrap();
666 assert_eq!(
667 value_to_strings(&Value::Cell(cell)).unwrap(),
668 vec!["testOne".to_string(), "testTwo".to_string()]
669 );
670 }
671
672 #[test]
673 fn result_value_returns_test_result_objects() {
674 let value = runtests_result_value(vec![RunTestOutcome {
675 name: "testSmoke".to_string(),
676 source_path: PathBuf::from("/tmp/testSmoke.m"),
677 passed: true,
678 duration_seconds: 0.1,
679 details: String::new(),
680 }])
681 .expect("result");
682 let Value::Object(obj) = value else {
683 panic!("expected object result");
684 };
685 assert!(obj.is_class("matlab.unittest.TestResult"));
686 assert_eq!(obj.properties.get("Passed"), Some(&Value::Bool(true)));
687 }
688
689 #[test]
690 fn result_value_returns_cell_for_multiple_results() {
691 let value = runtests_result_value(vec![
692 RunTestOutcome {
693 name: "testA".to_string(),
694 source_path: PathBuf::from("/tmp/testA.m"),
695 passed: true,
696 duration_seconds: 0.0,
697 details: String::new(),
698 },
699 RunTestOutcome {
700 name: "testB".to_string(),
701 source_path: PathBuf::from("/tmp/testB.m"),
702 passed: false,
703 duration_seconds: 0.0,
704 details: "failed".to_string(),
705 },
706 ])
707 .expect("result");
708 let Value::Cell(cell) = value else {
709 panic!("expected cell result");
710 };
711 assert_eq!(cell.rows, 1);
712 assert_eq!(cell.cols, 2);
713 }
714
715 #[test]
716 fn discovers_matlab_test_file_names() {
717 assert!(is_test_file(Path::new("testSmoke.m")));
718 assert!(is_test_file(Path::new("SmokeTest.m")));
719 assert!(!is_test_file(Path::new("helper.m")));
720 assert!(!is_test_file(Path::new("testSmoke.txt")));
721 }
722
723 #[test]
724 fn discovers_function_test_names() {
725 let names = function_test_names(
726 r#"
727function helper()
728end
729function testAlpha()
730end
731function out = betaTest()
732end
733"#,
734 );
735 assert_eq!(names, vec!["betaTest".to_string(), "testAlpha".to_string()]);
736 }
737
738 #[test]
739 fn string_array_targets_are_flattened() {
740 let array = StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).unwrap();
741 assert_eq!(
742 value_to_strings(&Value::StringArray(array)).unwrap(),
743 vec!["a".to_string(), "b".to_string()]
744 );
745 }
746}