Skip to main content

runmat_runtime/builtins/diagnostics/
runtests.rs

1//! MATLAB-compatible `runtests` discovery and result helpers.
2//!
3//! Runtime owns argument parsing, filesystem discovery, and result shaping. The
4//! VM owns execution because test files run compiled RunMat source.
5
6use 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(&current)
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}