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