Skip to main content

runmat_runtime/builtins/duration/
mod.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use runmat_builtins::{
5    Access, CharArray, ClassDef, MethodDef, ObjectInstance, PropertyDef, StringArray, Tensor, Value,
6};
7
8use crate::builtins::common::tensor;
9use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
10
11const BUILTIN_NAME: &str = "duration";
12const DURATION_CLASS: &str = "duration";
13const DAYS_FIELD: &str = "__days";
14const FORMAT_FIELD: &str = "Format";
15pub(crate) const DEFAULT_DURATION_FORMAT: &str = "hh:mm:ss";
16const SECONDS_PER_DAY: f64 = 86_400.0;
17
18static DURATION_CLASS_REGISTERED: OnceLock<()> = OnceLock::new();
19
20fn duration_error(message: impl Into<String>) -> RuntimeError {
21    build_runtime_error(message)
22        .with_builtin(BUILTIN_NAME)
23        .build()
24}
25
26fn ensure_duration_class_registered() {
27    DURATION_CLASS_REGISTERED.get_or_init(|| {
28        let mut properties = HashMap::new();
29        properties.insert(
30            FORMAT_FIELD.to_string(),
31            PropertyDef {
32                name: FORMAT_FIELD.to_string(),
33                is_static: false,
34                is_dependent: false,
35                get_access: Access::Public,
36                set_access: Access::Public,
37                default_value: Some(Value::String(DEFAULT_DURATION_FORMAT.to_string())),
38            },
39        );
40
41        let mut methods = HashMap::new();
42        for name in [
43            "subsref", "subsasgn", "plus", "minus", "eq", "ne", "lt", "le", "gt", "ge",
44        ] {
45            methods.insert(
46                name.to_string(),
47                MethodDef {
48                    name: name.to_string(),
49                    is_static: false,
50                    access: Access::Public,
51                    function_name: format!("{DURATION_CLASS}.{name}"),
52                },
53            );
54        }
55
56        runmat_builtins::register_class(ClassDef {
57            name: DURATION_CLASS.to_string(),
58            parent: None,
59            properties,
60            methods,
61        });
62    });
63}
64
65pub fn is_duration_object(value: &Value) -> bool {
66    matches!(value, Value::Object(obj) if obj.is_class(DURATION_CLASS))
67}
68
69async fn gather_args(args: &[Value]) -> BuiltinResult<Vec<Value>> {
70    let mut out = Vec::with_capacity(args.len());
71    for arg in args {
72        out.push(
73            gather_if_needed_async(arg)
74                .await
75                .map_err(|err| duration_error(format!("duration: {}", err.message())))?,
76        );
77    }
78    Ok(out)
79}
80
81fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
82    match value {
83        Value::String(text) => Ok(text.clone()),
84        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
85        Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
86        _ => Err(duration_error(format!(
87            "duration: {context} must be a string scalar or character vector"
88        ))),
89    }
90}
91
92fn parse_trailing_format(args: &[Value]) -> BuiltinResult<(usize, Option<String>)> {
93    let mut positional_end = args.len();
94    let mut format = None;
95
96    while positional_end >= 2 {
97        let name = match scalar_text(&args[positional_end - 2], "option name") {
98            Ok(text) => text,
99            Err(_) => break,
100        };
101        if !name.trim().eq_ignore_ascii_case("format") {
102            break;
103        }
104        format = Some(scalar_text(&args[positional_end - 1], "Format option")?);
105        positional_end -= 2;
106    }
107
108    Ok((positional_end, format))
109}
110
111fn tensor_from_numeric(value: Value, context: &str) -> BuiltinResult<Tensor> {
112    tensor::value_into_tensor_for(context, value)
113        .map_err(|message| duration_error(format!("duration: {message}")))
114}
115
116fn component_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
117    let tensor = tensor_from_numeric(value, context)?;
118    Tensor::new(
119        tensor.data.clone(),
120        tensor::default_shape_for(&tensor.shape, tensor.data.len()),
121    )
122    .map_err(|err| duration_error(format!("duration: {err}")))
123}
124
125fn format_for_object(obj: &ObjectInstance) -> String {
126    match obj.properties.get(FORMAT_FIELD) {
127        Some(Value::String(text)) => text.clone(),
128        Some(Value::StringArray(array)) if array.data.len() == 1 => array.data[0].clone(),
129        Some(Value::CharArray(array)) if array.rows == 1 => array.data.iter().collect(),
130        _ => DEFAULT_DURATION_FORMAT.to_string(),
131    }
132}
133
134pub(crate) fn duration_tensor_from_duration_value(value: &Value) -> BuiltinResult<Tensor> {
135    match value {
136        Value::Object(obj) if obj.is_class(DURATION_CLASS) => {
137            match obj.properties.get(DAYS_FIELD) {
138                Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
139                Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
140                    .map_err(|err| duration_error(format!("duration: {err}"))),
141                Some(other) => Err(duration_error(format!(
142                    "duration: invalid internal day storage {other:?}"
143                ))),
144                None => Err(duration_error("duration: missing internal day storage")),
145            }
146        }
147        _ => Err(duration_error("duration: expected a duration value")),
148    }
149}
150
151pub(crate) fn duration_format_from_value(value: &Value) -> String {
152    match value {
153        Value::Object(obj) if obj.is_class(DURATION_CLASS) => format_for_object(obj),
154        _ => DEFAULT_DURATION_FORMAT.to_string(),
155    }
156}
157
158pub(crate) fn duration_object_from_days_tensor(
159    days: Tensor,
160    format: impl Into<String>,
161) -> BuiltinResult<Value> {
162    ensure_duration_class_registered();
163    let mut object = ObjectInstance::new(DURATION_CLASS.to_string());
164    object
165        .properties
166        .insert(DAYS_FIELD.to_string(), Value::Tensor(days));
167    object
168        .properties
169        .insert(FORMAT_FIELD.to_string(), Value::String(format.into()));
170    Ok(Value::Object(object))
171}
172
173fn duration_object_from_days(
174    days: Vec<f64>,
175    shape: Vec<usize>,
176    format: impl Into<String>,
177) -> BuiltinResult<Value> {
178    let tensor =
179        Tensor::new(days, shape).map_err(|err| duration_error(format!("duration: {err}")))?;
180    duration_object_from_days_tensor(tensor, format)
181}
182
183fn broadcast_component_data(
184    arrays: &[Tensor],
185    labels: &[&str],
186) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
187    let mut target_shape = vec![1, 1];
188    let mut target_len = 1usize;
189
190    for array in arrays {
191        let len = array.data.len();
192        if len > 1 {
193            let shape = tensor::default_shape_for(&array.shape, len);
194            if target_len == 1 {
195                target_len = len;
196                target_shape = shape;
197            } else if len != target_len || shape != target_shape {
198                return Err(duration_error(
199                    "duration: non-scalar component inputs must have matching sizes",
200                ));
201            }
202        }
203    }
204
205    let mut broadcasted = Vec::with_capacity(arrays.len());
206    for (idx, array) in arrays.iter().enumerate() {
207        if array.data.len() == 1 {
208            broadcasted.push(vec![array.data[0]; target_len]);
209        } else if array.data.len() == target_len {
210            broadcasted.push(array.data.clone());
211        } else {
212            return Err(duration_error(format!(
213                "duration: {} input size does not match the other components",
214                labels[idx]
215            )));
216        }
217    }
218
219    Ok((broadcasted, target_shape))
220}
221
222fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
223    let labels = ["hours", "minutes", "seconds"];
224    let mut arrays = Vec::with_capacity(args.len());
225    for (idx, arg) in args.into_iter().enumerate() {
226        arrays.push(component_tensor(arg, labels[idx])?);
227    }
228    while arrays.len() < 3 {
229        arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
230    }
231
232    let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
233    let len = broadcasted[0].len();
234    let mut days = Vec::with_capacity(len);
235    for idx in 0..len {
236        let total_seconds =
237            broadcasted[0][idx] * 3600.0 + broadcasted[1][idx] * 60.0 + broadcasted[2][idx];
238        if !total_seconds.is_finite() {
239            return Err(duration_error("duration: component values must be finite"));
240        }
241        days.push(total_seconds / SECONDS_PER_DAY);
242    }
243
244    duration_object_from_days(
245        days,
246        shape,
247        format.unwrap_or_else(|| DEFAULT_DURATION_FORMAT.to_string()),
248    )
249}
250
251fn format_seconds_field(seconds: f64) -> String {
252    let whole = seconds.floor();
253    let fractional = seconds - whole;
254    if fractional.abs() <= 1e-9 {
255        format!("{:02}", whole as i64)
256    } else {
257        let mut text = format!("{:06.3}", seconds);
258        while text.contains('.') && text.ends_with('0') {
259            text.pop();
260        }
261        if text.ends_with('.') {
262            text.pop();
263        }
264        text
265    }
266}
267
268fn format_duration_value(days: f64, format: &str) -> BuiltinResult<String> {
269    if !days.is_finite() {
270        return Err(duration_error("duration: values must be finite"));
271    }
272
273    let total_seconds = days * SECONDS_PER_DAY;
274    let sign = if total_seconds < 0.0 { "-" } else { "" };
275    let total_seconds = total_seconds.abs();
276    let total_hours = (total_seconds / 3600.0).floor();
277    let total_minutes = (total_seconds / 60.0).floor();
278    let hours = total_hours as i64;
279    let minutes_component = ((total_seconds / 60.0).floor() as i64) % 60;
280    let seconds_component =
281        total_seconds - (hours as f64 * 3600.0) - (minutes_component as f64 * 60.0);
282
283    let rendered = match format {
284        "hh:mm:ss" => format!(
285            "{sign}{hours:02}:{minutes_component:02}:{}",
286            format_seconds_field(seconds_component)
287        ),
288        "hh:mm" => format!("{sign}{hours:02}:{minutes_component:02}"),
289        "mm:ss" => format!(
290            "{sign}{:02}:{}",
291            total_minutes as i64,
292            format_seconds_field(total_seconds - total_minutes * 60.0)
293        ),
294        "s" | "ss" => {
295            let mut text = format!("{:.3}", total_seconds);
296            while text.contains('.') && text.ends_with('0') {
297                text.pop();
298            }
299            if text.ends_with('.') {
300                text.pop();
301            }
302            format!("{sign}{text}")
303        }
304        other => {
305            return Err(duration_error(format!(
306                "duration: unsupported Format value '{other}'"
307            )))
308        }
309    };
310
311    Ok(rendered)
312}
313
314pub fn duration_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
315    let Value::Object(obj) = value else {
316        return Ok(None);
317    };
318    if !obj.is_class(DURATION_CLASS) {
319        return Ok(None);
320    }
321    let days = duration_tensor_from_duration_value(value)?;
322    let format = format_for_object(obj);
323    let mut strings = Vec::with_capacity(days.data.len());
324    for value in &days.data {
325        strings.push(format_duration_value(*value, &format)?);
326    }
327    let shape = tensor::default_shape_for(&days.shape, days.data.len());
328    let array = StringArray::new(strings, shape)
329        .map_err(|err| duration_error(format!("duration: {err}")))?;
330    Ok(Some(array))
331}
332
333pub fn duration_display_text(value: &Value) -> BuiltinResult<Option<String>> {
334    let Some(array) = duration_string_array(value)? else {
335        return Ok(None);
336    };
337    if array.data.len() == 1 {
338        return Ok(Some(array.data[0].clone()));
339    }
340
341    let rows = array.rows;
342    let cols = array.cols;
343    let mut widths = vec![0usize; cols];
344    for col in 0..cols {
345        for row in 0..rows {
346            let idx = row + col * rows;
347            widths[col] = widths[col].max(array.data[idx].len());
348        }
349    }
350
351    let mut lines = Vec::with_capacity(rows);
352    for row in 0..rows {
353        let mut line = String::new();
354        for col in 0..cols {
355            if col > 0 {
356                line.push_str("  ");
357            }
358            let idx = row + col * rows;
359            let text = &array.data[idx];
360            line.push_str(text);
361            let padding = widths[col].saturating_sub(text.len());
362            if padding > 0 {
363                line.push_str(&" ".repeat(padding));
364            }
365        }
366        lines.push(line);
367    }
368
369    Ok(Some(lines.join("\n")))
370}
371
372pub fn duration_summary(value: &Value) -> BuiltinResult<Option<String>> {
373    let Value::Object(obj) = value else {
374        return Ok(None);
375    };
376    if !obj.is_class(DURATION_CLASS) {
377        return Ok(None);
378    }
379    let days = duration_tensor_from_duration_value(value)?;
380    if days.data.len() == 1 {
381        return duration_display_text(value);
382    }
383    let shape = tensor::default_shape_for(&days.shape, days.data.len());
384    Ok(Some(format!(
385        "[{} duration]",
386        shape
387            .iter()
388            .map(|dim| dim.to_string())
389            .collect::<Vec<_>>()
390            .join("x")
391    )))
392}
393
394pub fn duration_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
395    let Some(array) = duration_string_array(value)? else {
396        return Ok(None);
397    };
398    let width = array.data.iter().map(String::len).max().unwrap_or(0);
399    let rows = array.data.len();
400    let mut data = vec![' '; rows * width];
401    for (row, text) in array.data.iter().enumerate() {
402        for (col, ch) in text.chars().enumerate() {
403            data[row * width + col] = ch;
404        }
405    }
406    let out = CharArray::new(data, rows, width)
407        .map_err(|err| duration_error(format!("duration: {err}")))?;
408    Ok(Some(out))
409}
410
411fn compare_duration(
412    lhs: Value,
413    rhs: Value,
414    op: &str,
415    cmp: impl Fn(f64, f64) -> bool,
416) -> BuiltinResult<Value> {
417    let lhs_days = duration_tensor_from_duration_value(&lhs)?;
418    let rhs_days = duration_tensor_from_duration_value(&rhs)?;
419    let (left, right, shape) =
420        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, op, BUILTIN_NAME)?;
421    let out = left
422        .iter()
423        .zip(right.iter())
424        .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
425        .collect::<Vec<_>>();
426    if out.len() == 1 {
427        Ok(Value::Num(out[0]))
428    } else {
429        Ok(Value::Tensor(Tensor::new(out, shape).map_err(|err| {
430            duration_error(format!("duration: {err}"))
431        })?))
432    }
433}
434
435async fn duration_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
436    let Value::Object(object) = obj else {
437        return Err(duration_error(
438            "duration.subsref: receiver must be a duration object",
439        ));
440    };
441    let format = format_for_object(&object);
442    let days = duration_tensor_from_duration_value(&Value::Object(object.clone()))?;
443
444    let Value::Cell(cell) = payload else {
445        return Err(duration_error(
446            "duration.subsref: indexing payload must be a cell array",
447        ));
448    };
449    if cell.data.is_empty() {
450        return duration_object_from_days_tensor(days, format);
451    }
452    if cell.data.len() != 1 {
453        return Err(duration_error(
454            "duration.subsref: only linear duration indexing is currently supported",
455        ));
456    }
457    let selector = (*cell.data[0]).clone();
458    let selector = match selector {
459        Value::Tensor(tensor) => tensor,
460        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
461            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
462        Value::Int(value) => Tensor::new(vec![value.to_f64()], vec![1, 1])
463            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
464        Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
465            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
466        other => {
467            return Err(duration_error(format!(
468                "duration.subsref: unsupported index value {other:?}"
469            )))
470        }
471    };
472    let indexed = crate::perform_indexing(&Value::Tensor(days), &selector.data)
473        .await
474        .map_err(|err| duration_error(format!("duration.subsref: {}", err.message())))?;
475    let indexed_days = match indexed {
476        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
477            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
478        Value::Tensor(tensor) => tensor,
479        other => {
480            return Err(duration_error(format!(
481                "duration.subsref: unexpected indexing result {other:?}"
482            )))
483        }
484    };
485    duration_object_from_days_tensor(indexed_days, format)
486}
487
488#[runmat_macros::runtime_builtin(
489    name = "duration",
490    builtin_path = "crate::builtins::duration",
491    category = "datetime",
492    summary = "Create MATLAB-compatible duration arrays from hour, minute, and second components.",
493    keywords = "duration,time span,elapsed time,Format",
494    related = "datetime,string,char,disp",
495    examples = "t = duration(1, 30, 45);"
496)]
497async fn duration_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
498    ensure_duration_class_registered();
499    let args = gather_args(&args).await?;
500    let (positional_end, format) = parse_trailing_format(&args)?;
501    let positional = args[..positional_end].to_vec();
502
503    match positional.len() {
504        1..=3 => build_from_components(positional, format),
505        _ => Err(duration_error(
506            "duration: unsupported argument pattern; use H/M/S numeric component inputs",
507        )),
508    }
509}
510
511#[runmat_macros::runtime_builtin(
512    name = "duration.subsref",
513    builtin_path = "crate::builtins::duration"
514)]
515async fn duration_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
516    match kind.as_str() {
517        "()" => duration_indexing(obj, payload).await,
518        "." => {
519            let Value::Object(object) = obj else {
520                return Err(duration_error(
521                    "duration.subsref: receiver must be a duration object",
522                ));
523            };
524            let field = scalar_text(&payload, "field selector")?;
525            match field.as_str() {
526                FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
527                _ => Err(duration_error(format!(
528                    "duration.subsref: unsupported duration property '{field}'"
529                ))),
530            }
531        }
532        other => Err(duration_error(format!(
533            "duration.subsref: unsupported indexing kind '{other}'"
534        ))),
535    }
536}
537
538#[runmat_macros::runtime_builtin(
539    name = "duration.subsasgn",
540    builtin_path = "crate::builtins::duration"
541)]
542async fn duration_subsasgn(
543    obj: Value,
544    kind: String,
545    payload: Value,
546    rhs: Value,
547) -> crate::BuiltinResult<Value> {
548    let Value::Object(mut object) = obj else {
549        return Err(duration_error(
550            "duration.subsasgn: receiver must be a duration object",
551        ));
552    };
553    match kind.as_str() {
554        "." => {
555            let field = scalar_text(&payload, "field selector")?;
556            match field.as_str() {
557                FORMAT_FIELD => {
558                    let text = scalar_text(&rhs, "Format value")?;
559                    object
560                        .properties
561                        .insert(FORMAT_FIELD.to_string(), Value::String(text));
562                    Ok(Value::Object(object))
563                }
564                _ => Err(duration_error(format!(
565                    "duration.subsasgn: unsupported duration property '{field}'"
566                ))),
567            }
568        }
569        _ => Err(duration_error(format!(
570            "duration.subsasgn: unsupported indexing kind '{kind}'"
571        ))),
572    }
573}
574
575#[runmat_macros::runtime_builtin(name = "duration.eq", builtin_path = "crate::builtins::duration")]
576async fn duration_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
577    compare_duration(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
578}
579
580#[runmat_macros::runtime_builtin(name = "duration.ne", builtin_path = "crate::builtins::duration")]
581async fn duration_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
582    compare_duration(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
583}
584
585#[runmat_macros::runtime_builtin(name = "duration.lt", builtin_path = "crate::builtins::duration")]
586async fn duration_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
587    compare_duration(lhs, rhs, "lt", |a, b| a < b)
588}
589
590#[runmat_macros::runtime_builtin(name = "duration.le", builtin_path = "crate::builtins::duration")]
591async fn duration_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
592    compare_duration(lhs, rhs, "le", |a, b| a <= b)
593}
594
595#[runmat_macros::runtime_builtin(name = "duration.gt", builtin_path = "crate::builtins::duration")]
596async fn duration_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
597    compare_duration(lhs, rhs, "gt", |a, b| a > b)
598}
599
600#[runmat_macros::runtime_builtin(name = "duration.ge", builtin_path = "crate::builtins::duration")]
601async fn duration_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
602    compare_duration(lhs, rhs, "ge", |a, b| a >= b)
603}
604
605#[runmat_macros::runtime_builtin(
606    name = "duration.plus",
607    builtin_path = "crate::builtins::duration"
608)]
609async fn duration_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
610    let lhs_days = duration_tensor_from_duration_value(&lhs)?;
611    if crate::builtins::datetime::is_datetime_object(&rhs) {
612        let rhs_serials = crate::builtins::datetime::serials_from_datetime_value(&rhs)?;
613        let (left, right, shape) =
614            tensor::binary_numeric_tensors(&lhs_days, &rhs_serials, "plus", BUILTIN_NAME)?;
615        let serials = left
616            .iter()
617            .zip(right.iter())
618            .map(|(a, b)| a + b)
619            .collect::<Vec<_>>();
620        let tensor =
621            Tensor::new(serials, shape).map_err(|err| duration_error(format!("plus: {err}")))?;
622        return crate::builtins::datetime::datetime_object_from_serial_tensor(
623            tensor,
624            crate::builtins::datetime::datetime_format_from_value(&rhs),
625        );
626    }
627
628    let rhs_days = duration_tensor_from_duration_value(&rhs)?;
629    let (left, right, shape) =
630        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "plus", BUILTIN_NAME)?;
631    let days = left
632        .iter()
633        .zip(right.iter())
634        .map(|(a, b)| a + b)
635        .collect::<Vec<_>>();
636    duration_object_from_days(days, shape, duration_format_from_value(&lhs))
637}
638
639#[runmat_macros::runtime_builtin(
640    name = "duration.minus",
641    builtin_path = "crate::builtins::duration"
642)]
643async fn duration_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
644    let lhs_days = duration_tensor_from_duration_value(&lhs)?;
645    let rhs_days = duration_tensor_from_duration_value(&rhs)?;
646    let (left, right, shape) =
647        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "minus", BUILTIN_NAME)?;
648    let days = left
649        .iter()
650        .zip(right.iter())
651        .map(|(a, b)| a - b)
652        .collect::<Vec<_>>();
653    duration_object_from_days(days, shape, duration_format_from_value(&lhs))
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    fn run_duration(args: Vec<Value>) -> Value {
661        futures::executor::block_on(duration_builtin(args)).expect("duration")
662    }
663
664    #[test]
665    fn duration_builds_from_components() {
666        let value = run_duration(vec![Value::Num(1.0), Value::Num(30.0), Value::Num(45.0)]);
667        let rendered = duration_display_text(&value)
668            .expect("display")
669            .expect("duration text");
670        assert_eq!(rendered, "01:30:45");
671    }
672
673    #[test]
674    fn duration_formats_arrays() {
675        let hours = Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap());
676        let minutes = Value::Tensor(Tensor::new(vec![15.0, 45.0], vec![1, 2]).unwrap());
677        let value = run_duration(vec![hours, minutes]);
678        let rendered = duration_display_text(&value)
679            .expect("display")
680            .expect("duration text");
681        assert!(rendered.contains("01:15:00"));
682        assert!(rendered.contains("02:45:00"));
683    }
684
685    #[test]
686    fn duration_supports_format_assignment_and_indexing() {
687        let value = run_duration(vec![Value::Num(1.0), Value::Num(5.0)]);
688        let updated = futures::executor::block_on(duration_subsasgn(
689            value.clone(),
690            ".".to_string(),
691            Value::String(FORMAT_FIELD.to_string()),
692            Value::String("hh:mm".to_string()),
693        ))
694        .expect("subsasgn");
695        let rendered = duration_display_text(&updated)
696            .expect("display")
697            .expect("duration text");
698        assert_eq!(rendered, "01:05");
699
700        let array = run_duration(vec![
701            Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
702            Value::Num(0.0),
703            Value::Num(0.0),
704        ]);
705        let payload =
706            Value::Cell(runmat_builtins::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
707        let indexed =
708            futures::executor::block_on(duration_subsref(array, "()".to_string(), payload))
709                .expect("subsref");
710        let text = duration_display_text(&indexed)
711            .expect("display")
712            .expect("duration text");
713        assert_eq!(text, "02:00:00");
714    }
715}