Skip to main content

runmat_runtime/builtins/strings/core/
int2str.rs

1//! MATLAB-compatible `int2str` builtin with GPU-aware host formatting.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
5    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
6    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
7    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
8    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
9    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10};
11use runmat_macros::runtime_builtin;
12use runmat_value::{CharArray, IntValue, IntegerStorage, Tensor, Value};
13
14use crate::builtins::common::gpu_helpers;
15use crate::builtins::common::map_control_flow_with_builtin;
16use crate::builtins::common::spec::{
17    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
18    ReductionNaN, ResidencyPolicy, ShapeRequirements,
19};
20use crate::builtins::common::tensor;
21use crate::builtins::strings::type_resolvers::string_scalar_type;
22use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
23
24const BUILTIN_NAME: &str = "int2str";
25
26const INT2STR_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
27    name: "chr",
28    ty: BuiltinParamType::Any,
29    arity: BuiltinParamArity::Required,
30    default: None,
31    description: "Character array containing the rounded integer text.",
32}];
33
34const INT2STR_INPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
35    name: "N",
36    ty: BuiltinParamType::Any,
37    arity: BuiltinParamArity::Required,
38    default: None,
39    description: "Numeric or logical scalar, vector, or matrix input.",
40}];
41
42const INT2STR_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
43    label: "chr = int2str(N)",
44    inputs: &INT2STR_INPUT,
45    outputs: &INT2STR_OUTPUT,
46}];
47
48const INT2STR_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
49    code: "RM.INT2STR.INVALID_INPUT",
50    identifier: Some("RunMat:int2str:InvalidInput"),
51    when: "Input value is not a supported numeric/logical scalar, vector, or matrix.",
52    message: "int2str: unsupported input type",
53};
54
55const INT2STR_ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
56    code: "RM.INT2STR.INVALID_OPTION",
57    identifier: Some("RunMat:int2str:InvalidOption"),
58    when: "Arguments are malformed or too many were supplied.",
59    message: "int2str: invalid option arguments",
60};
61
62const INT2STR_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
63    code: "RM.INT2STR.INTERNAL",
64    identifier: Some("RunMat:int2str:InternalError"),
65    when: "Internal char-array assembly failed.",
66    message: "int2str: internal error",
67};
68
69const INT2STR_ERRORS: [BuiltinErrorDescriptor; 3] = [
70    INT2STR_ERROR_INVALID_INPUT,
71    INT2STR_ERROR_INVALID_OPTION,
72    INT2STR_ERROR_INTERNAL,
73];
74
75pub const INT2STR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
76    signatures: &INT2STR_SIGNATURES,
77    output_mode: BuiltinOutputMode::Fixed,
78    completion_policy: BuiltinCompletionPolicy::Public,
79    errors: &INT2STR_ERRORS,
80};
81
82const INT2STR_LOGICAL_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
83    id: "int2str-logical-input",
84    mode: BuiltinExtensionMode::RunMatOnly,
85    description: "int2str with logical input is a RunMat extension",
86    error_identifier: Some("RunMat:compatibility:Int2strLogicalInputExtension"),
87};
88pub const INT2STR_EXTENSIONS: [BuiltinExtensionDescriptor; 1] = [INT2STR_LOGICAL_EXTENSION];
89
90const INT2STR_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 1] =
91    [BuiltinIntegerInputCapability {
92        name: "N",
93        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
94        availability: BuiltinIntegerInputAvailability::Documented,
95        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
96        notes: "Every integer class is formatted directly from authoritative storage, including wide values above flintmax.",
97    }];
98pub const INT2STR_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
99    [BuiltinIntegerCapabilityDescriptor {
100        form: "chr = int2str(integer_N)",
101        inputs: &INT2STR_INTEGER_INPUTS,
102        computation_domain: BuiltinIntegerComputationDomain::ExactInteger,
103        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
104        overflow: BuiltinIntegerOverflowRule::NotApplicable,
105        backend: BuiltinIntegerBackendRule::GatherFallback,
106        overload: BuiltinIntegerOverloadKind::FunctionSpecific,
107        notes: "Integer text is exact and the result is a host character array. Documented gpuArray input is downloaded through its owner because int2str does not execute on the GPU.",
108    }];
109
110fn int2str_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
111    int2str_error_with_message(error.message, error)
112}
113
114fn int2str_error_with_message(
115    message: impl Into<String>,
116    error: &'static BuiltinErrorDescriptor,
117) -> RuntimeError {
118    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
119    if let Some(identifier) = error.identifier {
120        builder = builder.with_identifier(identifier);
121    }
122    builder.build()
123}
124
125fn remap_int2str_flow(err: RuntimeError) -> RuntimeError {
126    map_control_flow_with_builtin(err, BUILTIN_NAME)
127}
128
129#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::strings::core::int2str")]
130pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
131    name: "int2str",
132    op_kind: GpuOpKind::Custom("conversion"),
133    supported_precisions: &[],
134    broadcast: BroadcastSemantics::None,
135    provider_hooks: &[],
136    constant_strategy: ConstantStrategy::InlineLiteral,
137    residency: ResidencyPolicy::GatherImmediately,
138    nan_mode: ReductionNaN::Include,
139    two_pass_threshold: None,
140    workgroup_size: None,
141    accepts_nan_mode: false,
142    notes: "Accepts GPU inputs but gathers them to host memory before rounding and formatting.",
143};
144
145#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::strings::core::int2str")]
146pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
147    name: "int2str",
148    shape: ShapeRequirements::Any,
149    constant_strategy: ConstantStrategy::InlineLiteral,
150    elementwise: None,
151    reduction: None,
152    emits_nan: false,
153    notes:
154        "Conversion builtin; not eligible for fusion and always materialises host character arrays.",
155};
156
157#[runtime_builtin(
158    name = "int2str",
159    category = "strings/core",
160    summary = "Convert rounded integer values to a character array.",
161    keywords = "int2str,integer to string,number to string,round",
162    examples = "chr = int2str([5 10 20; 100 200 400]);",
163    type_resolver(string_scalar_type),
164    descriptor(crate::builtins::strings::core::int2str::INT2STR_DESCRIPTOR),
165    extensions(crate::builtins::strings::core::int2str::INT2STR_EXTENSIONS),
166    integer_capabilities(crate::builtins::strings::core::int2str::INT2STR_INTEGER_CAPABILITIES),
167    builtin_path = "crate::builtins::strings::core::int2str"
168)]
169async fn int2str_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
170    if !rest.is_empty() {
171        return Err(int2str_error_with_message(
172            "int2str: too many input arguments",
173            &INT2STR_ERROR_INVALID_OPTION,
174        ));
175    }
176
177    if value_is_logical(&value) {
178        crate::compatibility::ensure_builtin_extension_enabled(
179            &INT2STR_LOGICAL_EXTENSION,
180            BUILTIN_NAME,
181        )?;
182    }
183
184    let gathered = gather_if_needed_async(&value)
185        .await
186        .map_err(remap_int2str_flow)?;
187    let data = extract_numeric_data(gathered).await?;
188    Ok(Value::CharArray(format_numeric_data(data)?))
189}
190
191fn value_is_logical(value: &Value) -> bool {
192    matches!(value, Value::Bool(_) | Value::LogicalArray(_))
193        || matches!(value, Value::GpuTensor(handle) if runmat_accelerate_api::handle_is_logical(handle))
194}
195
196enum NumericData {
197    Real {
198        data: Vec<f64>,
199        rows: usize,
200        cols: usize,
201    },
202    Integer {
203        storage: IntegerStorage,
204        rows: usize,
205        cols: usize,
206    },
207}
208
209async fn extract_numeric_data(value: Value) -> BuiltinResult<NumericData> {
210    match value {
211        Value::Num(n) => Ok(NumericData::Real {
212            data: vec![n],
213            rows: 1,
214            cols: 1,
215        }),
216        Value::Int(i) => Ok(NumericData::Integer {
217            storage: integer_storage_from_scalar(i),
218            rows: 1,
219            cols: 1,
220        }),
221        Value::Bool(b) => Ok(NumericData::Real {
222            data: vec![if b { 1.0 } else { 0.0 }],
223            rows: 1,
224            cols: 1,
225        }),
226        Value::Tensor(tensor) => tensor_to_numeric_data(tensor),
227        Value::LogicalArray(logical) => {
228            let tensor = tensor::logical_to_tensor(&logical)
229                .map_err(|_| int2str_error(&INT2STR_ERROR_INVALID_INPUT))?;
230            tensor_to_numeric_data(tensor)
231        }
232        Value::Complex(_, _) | Value::ComplexTensor(_) => Err(int2str_error_with_message(
233            "int2str: complex input is not supported",
234            &INT2STR_ERROR_INVALID_INPUT,
235        )),
236        Value::GpuTensor(handle) => {
237            let gathered = gpu_helpers::gather_tensor_async(&handle)
238                .await
239                .map_err(remap_int2str_flow)?;
240            tensor_to_numeric_data(gathered)
241        }
242        other => Err(int2str_error_with_message(
243            format!(
244                "{} {:?}; expected numeric or logical values",
245                INT2STR_ERROR_INVALID_INPUT.message, other
246            ),
247            &INT2STR_ERROR_INVALID_INPUT,
248        )),
249    }
250}
251
252fn tensor_to_numeric_data(tensor: Tensor) -> BuiltinResult<NumericData> {
253    if tensor.shape.len() > 2 {
254        return Err(int2str_error_with_message(
255            "int2str: input must be scalar, vector, or 2-D matrix",
256            &INT2STR_ERROR_INVALID_INPUT,
257        ));
258    }
259    let rows = tensor.rows();
260    let cols = tensor.cols();
261    let storage = tensor
262        .into_numeric_storage()
263        .map_err(|_| int2str_error(&INT2STR_ERROR_INVALID_INPUT))?;
264    match storage.into_integer_storage() {
265        Ok(storage) => Ok(NumericData::Integer {
266            storage,
267            rows,
268            cols,
269        }),
270        Err(storage) => Ok(NumericData::Real {
271            data: storage.materialize_f64(),
272            rows,
273            cols,
274        }),
275    }
276}
277
278#[derive(Clone)]
279struct CellEntry {
280    text: String,
281    width: usize,
282}
283
284fn format_numeric_data(data: NumericData) -> BuiltinResult<CharArray> {
285    match data {
286        NumericData::Real { data, rows, cols } => format_real_matrix(&data, rows, cols),
287        NumericData::Integer {
288            storage,
289            rows,
290            cols,
291        } => format_integer_matrix(&storage, rows, cols),
292    }
293}
294
295fn format_real_matrix(data: &[f64], rows: usize, cols: usize) -> BuiltinResult<CharArray> {
296    if rows == 0 || cols == 0 {
297        return char_array(Vec::new(), 0, 0);
298    }
299
300    format_entries(rows, cols, |row, col| {
301        let idx = row + col * rows;
302        format_rounded_real(data.get(idx).copied().unwrap_or(0.0))
303    })
304}
305
306fn format_integer_matrix(
307    storage: &IntegerStorage,
308    rows: usize,
309    cols: usize,
310) -> BuiltinResult<CharArray> {
311    if rows == 0 || cols == 0 {
312        return char_array(Vec::new(), 0, 0);
313    }
314
315    format_entries(rows, cols, |row, col| {
316        integer_storage_string(storage, row + col * rows)
317    })
318}
319
320fn integer_storage_from_scalar(value: IntValue) -> IntegerStorage {
321    match value {
322        IntValue::I8(value) => IntegerStorage::I8(vec![value]),
323        IntValue::I16(value) => IntegerStorage::I16(vec![value]),
324        IntValue::I32(value) => IntegerStorage::I32(vec![value]),
325        IntValue::I64(value) => IntegerStorage::I64(vec![value]),
326        IntValue::U8(value) => IntegerStorage::U8(vec![value]),
327        IntValue::U16(value) => IntegerStorage::U16(vec![value]),
328        IntValue::U32(value) => IntegerStorage::U32(vec![value]),
329        IntValue::U64(value) => IntegerStorage::U64(vec![value]),
330    }
331}
332
333fn integer_storage_string(storage: &IntegerStorage, index: usize) -> String {
334    match storage {
335        IntegerStorage::I8(values) => values[index].to_string(),
336        IntegerStorage::I16(values) => values[index].to_string(),
337        IntegerStorage::I32(values) => values[index].to_string(),
338        IntegerStorage::I64(values) => values[index].to_string(),
339        IntegerStorage::U8(values) => values[index].to_string(),
340        IntegerStorage::U16(values) => values[index].to_string(),
341        IntegerStorage::U32(values) => values[index].to_string(),
342        IntegerStorage::U64(values) => values[index].to_string(),
343    }
344}
345
346fn format_entries<F>(rows: usize, cols: usize, mut value_at: F) -> BuiltinResult<CharArray>
347where
348    F: FnMut(usize, usize) -> String,
349{
350    let total_cells = rows.checked_mul(cols).ok_or_else(|| {
351        int2str_error_with_message(
352            "int2str: output dimensions are too large",
353            &INT2STR_ERROR_INTERNAL,
354        )
355    })?;
356    if total_cells > isize::MAX as usize {
357        return Err(int2str_error_with_message(
358            "int2str: output dimensions are too large",
359            &INT2STR_ERROR_INTERNAL,
360        ));
361    }
362
363    let mut entries = vec![
364        vec![
365            CellEntry {
366                text: String::new(),
367                width: 0,
368            };
369            cols
370        ];
371        rows
372    ];
373    let mut col_widths = vec![0usize; cols];
374
375    for (col, width) in col_widths.iter_mut().enumerate() {
376        for (row, row_entries) in entries.iter_mut().enumerate() {
377            let text = value_at(row, col);
378            let entry_width = text.chars().count();
379            row_entries[col] = CellEntry {
380                text,
381                width: entry_width,
382            };
383            *width = (*width).max(entry_width);
384        }
385    }
386
387    if cols > 1 {
388        for (idx, width) in col_widths.iter_mut().enumerate() {
389            if idx > 0 {
390                *width += 1;
391            }
392        }
393    }
394
395    rows_to_char_array(assemble_rows(entries, col_widths))
396}
397
398fn format_rounded_real(value: f64) -> String {
399    format_integer_like(matlab_round(value))
400}
401
402fn matlab_round(value: f64) -> f64 {
403    if value.is_finite() {
404        value.round()
405    } else {
406        value
407    }
408}
409
410fn format_integer_like(value: f64) -> String {
411    if value.is_nan() {
412        return "NaN".to_string();
413    }
414    if value.is_infinite() {
415        return if value.is_sign_negative() {
416            "-Inf".to_string()
417        } else {
418            "Inf".to_string()
419        };
420    }
421    if value == 0.0 {
422        return "0".to_string();
423    }
424    if value >= i64::MIN as f64 && value <= i64::MAX as f64 {
425        return format!("{}", value as i64);
426    }
427    format!("{value:.0}")
428}
429
430fn assemble_rows(entries: Vec<Vec<CellEntry>>, col_widths: Vec<usize>) -> Vec<String> {
431    entries
432        .into_iter()
433        .map(|row_entries| {
434            row_entries
435                .into_iter()
436                .enumerate()
437                .fold(String::new(), |mut acc, (col, entry)| {
438                    if col > 0 {
439                        acc.push(' ');
440                    }
441                    let target = col_widths[col];
442                    let pad = target.saturating_sub(entry.width);
443                    acc.extend(std::iter::repeat_n(' ', pad));
444                    acc.push_str(&entry.text);
445                    acc
446                })
447        })
448        .collect()
449}
450
451fn rows_to_char_array(rows: Vec<String>) -> BuiltinResult<CharArray> {
452    if rows.is_empty() {
453        return char_array(Vec::new(), 0, 0);
454    }
455
456    let row_count = rows.len();
457    let col_count = rows
458        .iter()
459        .map(|row| row.chars().count())
460        .max()
461        .unwrap_or(0);
462    let capacity = row_count.checked_mul(col_count).ok_or_else(|| {
463        int2str_error_with_message(
464            "int2str: output dimensions are too large",
465            &INT2STR_ERROR_INTERNAL,
466        )
467    })?;
468
469    let mut data = Vec::with_capacity(capacity);
470    for row in rows {
471        let mut chars: Vec<char> = row.chars().collect();
472        if chars.len() < col_count {
473            chars.extend(std::iter::repeat_n(' ', col_count - chars.len()));
474        }
475        data.extend(chars);
476    }
477    char_array(data, row_count, col_count)
478}
479
480fn char_array(data: Vec<char>, rows: usize, cols: usize) -> BuiltinResult<CharArray> {
481    let expected = rows.checked_mul(cols).ok_or_else(|| {
482        int2str_error_with_message(
483            "int2str: output dimensions are too large",
484            &INT2STR_ERROR_INTERNAL,
485        )
486    })?;
487    if expected != data.len() {
488        return Err(int2str_error(&INT2STR_ERROR_INTERNAL));
489    }
490    CharArray::new(data, rows, cols).map_err(|_| int2str_error(&INT2STR_ERROR_INTERNAL))
491}
492
493#[cfg(test)]
494pub(crate) mod tests {
495    use super::*;
496    use crate::builtins::common::test_support;
497    use runmat_builtins::{ResolveContext, Type};
498    use runmat_value::{IntValue, LogicalArray};
499
500    fn int2str_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
501        futures::executor::block_on(super::int2str_builtin(value, rest))
502    }
503
504    fn char_rows(value: Value) -> Vec<String> {
505        match value {
506            Value::CharArray(ca) => ca
507                .data
508                .chunks(ca.cols)
509                .map(|chunk| chunk.iter().collect())
510                .collect(),
511            other => panic!("expected char array, got {other:?}"),
512        }
513    }
514
515    fn error_message(err: RuntimeError) -> String {
516        err.message().to_string()
517    }
518
519    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
520    #[test]
521    fn int2str_rounds_scalar_float() {
522        let out = int2str_builtin(Value::Num(3.25), Vec::new()).expect("int2str");
523        assert_eq!(char_rows(out), vec!["3"]);
524
525        let out = int2str_builtin(Value::Num(4.5), Vec::new()).expect("int2str");
526        assert_eq!(char_rows(out), vec!["5"]);
527
528        let out = int2str_builtin(Value::Num(-4.5), Vec::new()).expect("int2str");
529        assert_eq!(char_rows(out), vec!["-5"]);
530    }
531
532    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
533    #[test]
534    fn int2str_preserves_exact_uint64_scalars_and_arrays() {
535        let scalar = int2str_builtin(Value::Int(IntValue::U64(u64::MAX)), Vec::new())
536            .expect("uint64 scalar");
537        assert_eq!(char_rows(scalar), vec![u64::MAX.to_string()]);
538
539        let tensor =
540            Tensor::new_integer(IntegerStorage::U64(vec![u64::MAX, 1_u64 << 63]), vec![1, 2])
541                .expect("uint64 tensor");
542        let matrix = int2str_builtin(Value::Tensor(tensor), Vec::new()).expect("uint64 array");
543        assert_eq!(
544            char_rows(matrix),
545            vec![format!("{}  {}", u64::MAX, 1_u64 << 63)]
546        );
547    }
548
549    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
550    #[test]
551    fn int2str_formats_integer_matrix_with_column_alignment() {
552        let tensor =
553            Tensor::new(vec![5.0, 100.0, 10.0, 200.0, 20.0, 400.0], vec![2, 3]).expect("tensor");
554        let out = int2str_builtin(Value::Tensor(tensor), Vec::new()).expect("int2str");
555        assert_eq!(char_rows(out), vec!["  5   10   20", "100  200  400"]);
556    }
557
558    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
559    #[test]
560    fn int2str_accepts_int_and_logical_inputs() {
561        let out = int2str_builtin(Value::Int(IntValue::I32(-12)), Vec::new()).expect("int2str int");
562        assert_eq!(char_rows(out), vec!["-12"]);
563
564        let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).expect("logical");
565        let error = int2str_builtin(Value::LogicalArray(logical.clone()), Vec::new())
566            .expect_err("MATLAB-compatible mode rejects logical input");
567        assert_eq!(
568            error.identifier(),
569            INT2STR_LOGICAL_EXTENSION.error_identifier
570        );
571        let _guard = crate::compatibility::push_runmat_extensions_enabled(true);
572        let out = int2str_builtin(Value::LogicalArray(logical), Vec::new())
573            .expect("RunMat logical extension");
574        assert_eq!(char_rows(out), vec!["1  0  1"]);
575    }
576
577    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
578    #[test]
579    fn int2str_preserves_nonfinite_text() {
580        let tensor = Tensor::new(vec![f64::NAN, f64::INFINITY, f64::NEG_INFINITY], vec![1, 3])
581            .expect("tensor");
582        let out = int2str_builtin(Value::Tensor(tensor), Vec::new()).expect("int2str");
583        assert_eq!(char_rows(out), vec!["NaN  Inf  -Inf"]);
584    }
585
586    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
587    #[test]
588    fn int2str_empty_matrix_shapes() {
589        let empty = Tensor::new(Vec::new(), vec![0, 3]).expect("empty rows");
590        let out = int2str_builtin(Value::Tensor(empty), Vec::new()).expect("int2str");
591        match out {
592            Value::CharArray(ca) => {
593                assert_eq!(ca.rows, 0);
594                assert_eq!(ca.cols, 0);
595                assert!(ca.data.is_empty());
596            }
597            other => panic!("expected char array, got {other:?}"),
598        }
599
600        let empty_cols = Tensor::new(Vec::new(), vec![2, 0]).expect("empty cols");
601        let out = int2str_builtin(Value::Tensor(empty_cols), Vec::new()).expect("int2str");
602        match out {
603            Value::CharArray(ca) => {
604                assert_eq!(ca.rows, 0);
605                assert_eq!(ca.cols, 0);
606                assert!(ca.data.is_empty());
607            }
608            other => panic!("expected char array, got {other:?}"),
609        }
610    }
611
612    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
613    #[test]
614    fn int2str_gpu_tensor_roundtrip() {
615        test_support::with_test_provider(|provider| {
616            let tensor = Tensor::new(vec![10.2, 20.8], vec![1, 2]).expect("tensor");
617            let view = runmat_accelerate_api::HostTensorView {
618                data: &tensor.materialize_f64(),
619                shape: &tensor.shape,
620            };
621            let handle = provider.upload(&view).expect("upload");
622            let out = int2str_builtin(Value::GpuTensor(handle), Vec::new()).expect("int2str");
623            assert_eq!(char_rows(out), vec!["10  21"]);
624        });
625    }
626
627    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
628    #[test]
629    fn int2str_rejects_complex_input() {
630        let err = error_message(int2str_builtin(Value::Complex(3.0, 4.0), Vec::new()).unwrap_err());
631        assert!(err.contains("complex input is not supported"));
632    }
633
634    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
635    #[test]
636    fn int2str_rejects_extra_arguments() {
637        let err =
638            error_message(int2str_builtin(Value::Num(1.0), vec![Value::Num(2.0)]).unwrap_err());
639        assert!(err.contains("too many input arguments"));
640    }
641
642    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
643    #[test]
644    fn int2str_rejects_non_numeric_input() {
645        let err =
646            error_message(int2str_builtin(Value::String("hello".into()), Vec::new()).unwrap_err());
647        assert!(err.contains("unsupported input type"));
648    }
649
650    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
651    #[test]
652    fn int2str_rejects_nd_input() {
653        let tensor = Tensor::new(vec![1.0; 8], vec![2, 2, 2]).expect("tensor");
654        let err = error_message(int2str_builtin(Value::Tensor(tensor), Vec::new()).unwrap_err());
655        assert!(err.contains("scalar, vector, or 2-D matrix"));
656    }
657
658    #[test]
659    fn int2str_type_is_string_scalar() {
660        assert_eq!(
661            string_scalar_type(&[Type::Num], &ResolveContext::new(Vec::new())),
662            Type::String
663        );
664    }
665}