Skip to main content

runmat_runtime/builtins/diagnostics/
runtests.rs

1//! MATLAB-compatible `runtests` discovery and result helpers.
2//!
3//! Runtime owns MATLAB argument parsing, filesystem target resolution, and
4//! result shaping. Core owns semantic discovery and execution.
5
6use 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(&current)
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}