diff --git a/crates/irtt-cli/src/cmd/client/output.rs b/crates/irtt-cli/src/cmd/client/output.rs index 7dda2ab..6a782b2 100644 --- a/crates/irtt-cli/src/cmd/client/output.rs +++ b/crates/irtt-cli/src/cmd/client/output.rs @@ -10,6 +10,7 @@ use irtt_client::{ }; use super::args::{HeaderMode, OutputFormat}; +use crate::cmd::format::{format_duration, format_signed_duration, ABSENT}; #[derive(Debug, Clone)] pub(super) struct OutputConfig { @@ -817,7 +818,7 @@ fn render_table_row(row: &OutputRow, columns: &[Column], context: RenderContext< } fn format_table_cell(column: &Column, value: Option) -> String { - let value = value.unwrap_or_else(|| "-".to_owned()); + let value = value.unwrap_or_else(|| ABSENT.to_owned()); let width = column.table_width(); if column.align_right() { format!("{value:>width$}") @@ -1161,34 +1162,6 @@ fn wall_time_ns(wall: SystemTime) -> Option { .map(|duration| duration.as_nanos()) } -fn format_duration(duration: Duration) -> String { - format_ns(duration.as_nanos() as f64) -} - -fn format_signed_duration(duration: SignedDuration) -> String { - format_signed_ns(duration.as_nanos() as f64) -} - -fn format_signed_ns(ns: f64) -> String { - if ns < 0.0 { - format!("-{}", format_ns(-ns)) - } else { - format_ns(ns) - } -} - -fn format_ns(ns: f64) -> String { - if ns < 1_000.0 { - format!("{ns:.0}ns") - } else if ns < 1_000_000.0 { - format!("{:.1}µs", ns / 1_000.0) - } else if ns < 1_000_000_000.0 { - format!("{:.1}ms", ns / 1_000_000.0) - } else { - format!("{:.3}s", ns / 1_000_000_000.0) - } -} - fn warning_kind(kind: WarningKind) -> &'static str { match kind { WarningKind::MalformedOrUnrelatedPacket => "malformed_or_unrelated_packet", diff --git a/crates/irtt-cli/src/cmd/client/summary.rs b/crates/irtt-cli/src/cmd/client/summary.rs index aef978b..1c43d44 100644 --- a/crates/irtt-cli/src/cmd/client/summary.rs +++ b/crates/irtt-cli/src/cmd/client/summary.rs @@ -2,6 +2,11 @@ use std::fmt::Write as _; use irtt_stats::{Snapshot, TimeStats}; +use crate::cmd::format::{ + format_ns_f64, format_optional_ns_f64 as format_ns_f64_opt, + format_optional_ns_i128 as format_ns_i128, format_percent, +}; + pub fn format_summary(summary: &Snapshot) -> String { format_summary_with_options(summary, SummaryFormatOptions::default()) } @@ -117,34 +122,6 @@ fn write_time_row(out: &mut String, label: &str, value: &TimeStats) { .unwrap(); } -fn format_percent(value: f64) -> String { - format!("{value:.2}%") -} - -fn format_ns_i128(value: Option) -> String { - value - .map(|value| format_ns_f64(value as f64)) - .unwrap_or_else(|| "-".to_owned()) -} - -fn format_ns_f64_opt(value: Option) -> String { - value.map(format_ns_f64).unwrap_or_else(|| "-".to_owned()) -} - -fn format_ns_f64(value: f64) -> String { - let sign = if value.is_sign_negative() { "-" } else { "" }; - let value = value.abs(); - if value < 1_000.0 { - format!("{sign}{value:.0}ns") - } else if value < 1_000_000.0 { - format!("{sign}{:.1}µs", value / 1_000.0) - } else if value < 1_000_000_000.0 { - format!("{sign}{:.1}ms", value / 1_000_000.0) - } else { - format!("{sign}{:.3}s", value / 1_000_000_000.0) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/irtt-cli/src/cmd/format.rs b/crates/irtt-cli/src/cmd/format.rs new file mode 100644 index 0000000..62f284b --- /dev/null +++ b/crates/irtt-cli/src/cmd/format.rs @@ -0,0 +1,181 @@ +//! Scalar presentation vocabulary shared by the CLI frontends. +//! +//! Stream output, the run summary, and the TUI display different metrics in +//! different layouts, but they render the same *scalars*: nanosecond-scale +//! durations, percentages, and counts. This module owns those spellings so the +//! frontends cannot drift apart on units, rounding, or how an absent value is +//! rendered. +//! +//! It is presentation only. Nothing here belongs in `irtt-stats`, and it is +//! deliberately not a general formatting framework: it holds exactly the +//! vocabulary more than one frontend needs. + +use std::time::Duration; + +use irtt_client::SignedDuration; + +/// Rendering used wherever a scalar is unavailable. +pub(crate) const ABSENT: &str = "-"; + +/// Format a nanosecond scalar. +/// +/// Unit selection and rounding are one policy for every frontend: +/// +/// | magnitude | unit | precision | +/// |-----------|------|-----------| +/// | `< 1µs` | `ns` | whole | +/// | `< 1ms` | `µs` | 1 decimal | +/// | `< 1s` | `ms` | 1 decimal | +/// | `>= 1s` | `s` | 3 decimals | +/// +/// Negative values are meaningful (clock offsets, one-way deltas, IPDV), so +/// the unit is selected from the magnitude and the sign is preserved. Values +/// that are not finite render as [`ABSENT`]. +pub(crate) fn format_ns_f64(value: f64) -> String { + if !value.is_finite() { + return ABSENT.to_owned(); + } + let sign = if value.is_sign_negative() { "-" } else { "" }; + let magnitude = value.abs(); + if magnitude < 1_000.0 { + format!("{sign}{magnitude:.0}ns") + } else if magnitude < 1_000_000.0 { + format!("{sign}{:.1}µs", magnitude / 1_000.0) + } else if magnitude < 1_000_000_000.0 { + format!("{sign}{:.1}ms", magnitude / 1_000_000.0) + } else { + format!("{sign}{:.3}s", magnitude / 1_000_000_000.0) + } +} + +/// Format an exact nanosecond scalar with [`format_ns_f64`]'s policy. +pub(crate) fn format_ns_i128(value: i128) -> String { + let sign = if value < 0 { "-" } else { "" }; + let magnitude = value.saturating_abs() as f64; + format!("{sign}{}", format_ns_f64(magnitude)) +} + +/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`]. +pub(crate) fn format_optional_ns_i128(value: Option) -> String { + value.map(format_ns_i128).unwrap_or_else(absent) +} + +/// Format an optional nanosecond scalar, rendering `None` as [`ABSENT`]. +pub(crate) fn format_optional_ns_f64(value: Option) -> String { + value.map(format_ns_f64).unwrap_or_else(absent) +} + +/// Format a duration as a nanosecond scalar. +pub(crate) fn format_duration(value: Duration) -> String { + format_ns_i128(i128::try_from(value.as_nanos()).unwrap_or(i128::MAX)) +} + +/// Format a signed duration as a nanosecond scalar, preserving its sign. +pub(crate) fn format_signed_duration(value: SignedDuration) -> String { + format_ns_i128(value.as_nanos()) +} + +/// Format a percentage with two decimals; non-finite values render as +/// [`ABSENT`]. +pub(crate) fn format_percent(value: f64) -> String { + if value.is_finite() { + format!("{value:.2}%") + } else { + ABSENT.to_owned() + } +} + +/// Format `value` as a percentage of `total`, capped at 100%. +/// +/// A zero total has no percentage to report and renders as [`ABSENT`]. +pub(crate) fn format_percent_ratio(value: u64, total: u64) -> String { + if total == 0 { + ABSENT.to_owned() + } else { + format_percent((value as f64 / total as f64 * 100.0).min(100.0)) + } +} + +/// Format a scalar count. +pub(crate) fn format_count(value: u64) -> String { + value.to_string() +} + +/// Format an optional scalar count, rendering `None` as [`ABSENT`]. +pub(crate) fn format_optional_count(value: Option) -> String { + value.map(format_count).unwrap_or_else(absent) +} + +fn absent() -> String { + ABSENT.to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nanosecond_scalars_select_units_at_the_defined_boundaries() { + assert_eq!(format_ns_f64(0.0), "0ns"); + assert_eq!(format_ns_f64(999.0), "999ns"); + assert_eq!(format_ns_f64(1_000.0), "1.0µs"); + assert_eq!(format_ns_f64(999_999.0), "1000.0µs"); + assert_eq!(format_ns_f64(1_000_000.0), "1.0ms"); + assert_eq!(format_ns_f64(999_999_999.0), "1000.0ms"); + assert_eq!(format_ns_f64(1_000_000_000.0), "1.000s"); + assert_eq!(format_ns_f64(1_500_000_000.0), "1.500s"); + } + + #[test] + fn negative_nanosecond_scalars_keep_their_sign() { + assert_eq!(format_ns_f64(-500.0), "-500ns"); + assert_eq!(format_ns_f64(-1_500.0), "-1.5µs"); + assert_eq!(format_ns_f64(-2_500_000.0), "-2.5ms"); + assert_eq!(format_ns_f64(-1_000_000_000.0), "-1.000s"); + assert_eq!(format_ns_i128(-1_500), "-1.5µs"); + } + + #[test] + fn exact_and_floating_nanosecond_scalars_agree() { + for value in [0_i128, 1, 999, 1_000, 1_234_567, -1_234_567, 5_000_000_000] { + assert_eq!(format_ns_i128(value), format_ns_f64(value as f64)); + } + } + + #[test] + fn microseconds_use_the_micro_sign() { + assert!(format_ns_f64(1_500.0).ends_with("µs")); + assert!(!format_ns_f64(1_500.0).contains("us")); + } + + #[test] + fn durations_render_as_nanosecond_scalars() { + assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms"); + assert_eq!(format_duration(Duration::from_secs(1)), "1.000s"); + assert_eq!( + format_signed_duration(SignedDuration::from_nanos(-1_500)), + "-1.5µs" + ); + } + + #[test] + fn absent_and_non_finite_scalars_render_as_a_placeholder() { + assert_eq!(format_optional_ns_i128(None), ABSENT); + assert_eq!(format_optional_ns_f64(None), ABSENT); + assert_eq!(format_optional_count(None), ABSENT); + assert_eq!(format_ns_f64(f64::NAN), ABSENT); + assert_eq!(format_ns_f64(f64::INFINITY), ABSENT); + assert_eq!(format_percent(f64::NAN), ABSENT); + assert_eq!(format_percent_ratio(1, 0), ABSENT); + } + + #[test] + fn percentages_and_counts_have_one_spelling() { + assert_eq!(format_percent(0.0), "0.00%"); + assert_eq!(format_percent(12.345), "12.35%"); + assert_eq!(format_percent_ratio(1, 4), "25.00%"); + assert_eq!(format_percent_ratio(5, 4), "100.00%"); + assert_eq!(format_count(42), "42"); + assert_eq!(format_optional_count(Some(0)), "0"); + } +} diff --git a/crates/irtt-cli/src/cmd/mod.rs b/crates/irtt-cli/src/cmd/mod.rs index 540d4af..834d440 100644 --- a/crates/irtt-cli/src/cmd/mod.rs +++ b/crates/irtt-cli/src/cmd/mod.rs @@ -1,5 +1,7 @@ #[cfg(feature = "client")] pub mod client; +#[cfg(any(feature = "client", feature = "tui"))] +mod format; #[cfg(feature = "server")] pub mod server; #[cfg(feature = "tui")] diff --git a/crates/irtt-cli/src/cmd/tui/ui.rs b/crates/irtt-cli/src/cmd/tui/ui.rs index d468bac..1697b50 100644 --- a/crates/irtt-cli/src/cmd/tui/ui.rs +++ b/crates/irtt-cli/src/cmd/tui/ui.rs @@ -23,7 +23,13 @@ use ratatui::{ }; use crate::{ - cmd::tui::args::TuiArgs, + cmd::{ + format::{ + format_count, format_duration, format_ns_f64, format_optional_count, + format_optional_ns_i128, format_percent, format_percent_ratio, ABSENT, + }, + tui::args::TuiArgs, + }, shared::client::{expected_probe_count, GroupPacingArg}, }; @@ -330,7 +336,7 @@ impl TuiState { recent = Some(format!( "reply seq={} effective={}", format_seq(*seq), - format_ns_i128(Some(rtt.effective.as_nanos())) + format_optional_ns_i128(Some(rtt.effective.as_nanos())) )); } ClientEvent::EchoLoss { seq, .. } => { @@ -349,7 +355,7 @@ impl TuiState { .map(|sample| { format!( " effective={}", - format_ns_i128(Some(sample.effective.as_nanos())) + format_optional_ns_i128(Some(sample.effective.as_nanos())) ) }) .unwrap_or_default(); @@ -1149,8 +1155,8 @@ fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> { let session = selected .and_then(|target| target.session.as_deref()) .unwrap_or("-"); - let elapsed = format_duration(state.started_at.elapsed()); - let duration = format_optional_duration(state.config.duration); + let elapsed = format_span(state.started_at.elapsed()); + let duration = format_optional_span(state.config.duration); let mode = if state.config.duration.is_some() { "finite" } else { @@ -1186,8 +1192,8 @@ fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> { )), Line::from(format!( "interval: {} timeout: {}", - format_duration(state.config.interval), - format_duration(state.config.timeout) + format_span(state.config.interval), + format_span(state.config.timeout) )), Line::from(negotiated), ]); @@ -1197,7 +1203,7 @@ fn header(state: &TuiState, density: HeaderDensity) -> Paragraph<'_> { Line::from(format!("session: {session}")), Line::from(format!( "{mode} elapsed: {elapsed} interval: {}", - format_duration(state.config.interval) + format_span(state.config.interval) )), ]); } @@ -1216,12 +1222,12 @@ fn graph_summary_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'stat let remote = selected .and_then(|target| target.remote.as_deref()) .unwrap_or("-"); - let elapsed = format_duration(state.started_at.elapsed()); + let elapsed = format_span(state.started_at.elapsed()); let packets = snapshot.packets; let last = state .selected_target() .and_then(|target| target.last_sample) - .map(|sample| format_ns_i128(Some(sample.effective_ns))) + .map(|sample| format_optional_ns_i128(Some(sample.effective_ns))) .unwrap_or_else(|| "-".to_owned()); let target_context = if state.is_multi_target() { @@ -1295,7 +1301,7 @@ fn packet_panel(state: &TuiState, snapshot: &Snapshot, panel_height: u16) -> Par )), Line::from(format!( "server received {} window {}", - format_optional_u64(packets.server_packets_received), + format_optional_count(packets.server_packets_received), format_optional_hex(packets.server_received_window) )), ]) @@ -1314,7 +1320,7 @@ fn target_table_panel(state: &TuiState, panel_height: u16) -> Paragraph<'static> let snapshot = target.stats.snapshot(); let last = target .last_sample - .map(|sample| format_ns_i128(Some(sample.effective_ns))) + .map(|sample| format_optional_ns_i128(Some(sample.effective_ns))) .unwrap_or_else(|| "-".to_owned()); lines.push(Line::from(vec![ target_label_span(&target.label, idx, 16), @@ -1695,7 +1701,7 @@ fn viewport_x_bounds(viewport: GraphViewportRange) -> [f64; 2] { fn viewport_x_axis_labels(viewport: GraphViewportRange) -> Vec> { vec![ - Span::raw(format!("-{}", format_duration(viewport.window))), + Span::raw(format!("-{}", format_span(viewport.window))), Span::raw(if viewport.is_live { "live" } else { "end" }), ] } @@ -1704,7 +1710,7 @@ fn graph_context(state: &TuiState, viewport: GraphViewportRange) -> String { format!( "{} | window {}", graph_viewport_status(state), - format_duration(viewport.window) + format_span(viewport.window) ) } @@ -1744,9 +1750,9 @@ fn format_axis_time_ms(value_ms: f64) -> String { if abs_ms < 1.0 { let us = abs_ms * 1_000.0; if us < 10.0 { - format!("{sign}{us:.1}us") + format!("{sign}{us:.1}µs") } else { - format!("{sign}{us:.0}us") + format!("{sign}{us:.0}µs") } } else if abs_ms < 1_000.0 { if abs_ms < 10.0 { @@ -1804,23 +1810,23 @@ fn sample_panel(state: &TuiState, snapshot: &Snapshot) -> Paragraph<'static> { )), Line::from(format!( "raw / adjusted / effective: {} / {} / {}", - last.map(|sample| format_ns_i128(Some(sample.raw_ns))) + last.map(|sample| format_optional_ns_i128(Some(sample.raw_ns))) .unwrap_or_else(|| "-".to_owned()), - last.map(|sample| format_ns_i128(sample.adjusted_ns)) + last.map(|sample| format_optional_ns_i128(sample.adjusted_ns)) .unwrap_or_else(|| "-".to_owned()), - last.map(|sample| format_ns_i128(Some(sample.effective_ns))) + last.map(|sample| format_optional_ns_i128(Some(sample.effective_ns))) .unwrap_or_else(|| "-".to_owned()) )), Line::from(format!( "one-way c2s / s2c: {} / {}", - last.map(|sample| format_ns_i128(sample.client_to_server_ns)) + last.map(|sample| format_optional_ns_i128(sample.client_to_server_ns)) .unwrap_or_else(|| "-".to_owned()), - last.map(|sample| format_ns_i128(sample.server_to_client_ns)) + last.map(|sample| format_optional_ns_i128(sample.server_to_client_ns)) .unwrap_or_else(|| "-".to_owned()) )), Line::from(format!( "server processing: {}", - last.map(|sample| format_ns_i128(sample.server_processing_ns)) + last.map(|sample| format_optional_ns_i128(sample.server_processing_ns)) .unwrap_or_else(|| "-".to_owned()) )), Line::from(format!( @@ -1888,7 +1894,7 @@ fn graph_viewport_status(state: &TuiState) -> String { let live_end = state.newest_graph_sample_time().unwrap_or(now).max(now); format!( "history -{}", - format_duration(live_end.saturating_duration_since(end)) + format_span(live_end.saturating_duration_since(end)) ) } } @@ -1906,9 +1912,9 @@ fn push_time_line(lines: &mut Vec>, label: &str, stats: &TimeStats) { lines.push(Line::from(format!( "{label:<18} {:>5} {:>9} {:>9} {:>9} {:>9}", format_count(stats.count), - format_ns_i128(stats.min_ns), + format_optional_ns_i128(stats.min_ns), format_ns_f64(stats.mean_ns), - format_ns_i128(stats.max_ns), + format_optional_ns_i128(stats.max_ns), format_ns_f64(stats.stddev_ns()) ))); } @@ -1918,7 +1924,7 @@ fn format_negotiated(negotiated: &NegotiatedParams) -> String { let duration = if params.duration_ns == 0 { "-".to_owned() } else { - format_ns_i128(Some(i128::from(params.duration_ns))) + format_optional_ns_i128(Some(i128::from(params.duration_ns))) }; let restrictions = if negotiated.restrictions.is_empty() { "none".to_owned() @@ -1928,7 +1934,7 @@ fn format_negotiated(negotiated: &NegotiatedParams) -> String { format!( "duration={} interval={} length={} clock={:?} timestamps={:?} stats={:?} restrictions={}", duration, - format_ns_i128(Some(i128::from(params.interval_ns))), + format_optional_ns_i128(Some(i128::from(params.interval_ns))), params.length, params.clock, params.stamp_at, @@ -1937,80 +1943,43 @@ fn format_negotiated(negotiated: &NegotiatedParams) -> String { ) } -fn format_optional_duration(value: Option) -> String { - value.map(format_duration).unwrap_or_else(|| "-".to_owned()) +fn format_optional_span(value: Option) -> String { + value.map(format_span).unwrap_or_else(|| ABSENT.to_owned()) } -fn format_duration(value: Duration) -> String { +/// Format a wall-clock span shown in the TUI's headers, config lines, and +/// graph window labels. +/// +/// Spans are read at a glance rather than compared numerically, so they are +/// deliberately coarser than the shared scalar policy: a zero span is `0s`, +/// whole seconds carry one decimal, and a span of a minute or more is written +/// as minutes and seconds. Below one second there is nothing to gain from a +/// second spelling, so the shared scalar vocabulary is used directly. +fn format_span(value: Duration) -> String { if value.is_zero() { return "0s".to_owned(); } let nanos = value.as_nanos(); - if nanos < 1_000 { - format!("{nanos}ns") - } else if nanos < 1_000_000 { - format!("{:.1}us", nanos as f64 / 1_000.0) - } else if nanos < 1_000_000_000 { - format!("{:.1}ms", nanos as f64 / 1_000_000.0) - } else if nanos < 60_000_000_000 { + if nanos < 1_000_000_000 { + return format_duration(value); + } + let secs = value.as_secs(); + if nanos < 60_000_000_000 { format!("{:.1}s", nanos as f64 / 1_000_000_000.0) } else { - let secs = value.as_secs(); format!("{}m{:02}s", secs / 60, secs % 60) } } -fn format_ns_i128(value: Option) -> String { - value.map(format_ns_value).unwrap_or_else(|| "-".to_owned()) -} - -fn format_ns_f64(value: f64) -> String { - format_ns_value(value.round() as i128) -} - -fn format_ns_value(value: i128) -> String { - let sign = if value < 0 { "-" } else { "" }; - let value = value.saturating_abs() as f64; - if value < 1_000.0 { - format!("{sign}{value:.0}ns") - } else if value < 1_000_000.0 { - format!("{sign}{:.1}us", value / 1_000.0) - } else if value < 1_000_000_000.0 { - format!("{sign}{:.1}ms", value / 1_000_000.0) - } else { - format!("{sign}{:.3}s", value / 1_000_000_000.0) - } -} - -fn format_percent(value: f64) -> String { - if value.is_finite() { - format!("{value:.2}%") - } else { - "-".to_owned() - } -} - -fn format_percent_ratio(value: u64, total: u64) -> String { - if total == 0 { - "-".to_owned() - } else { - format_percent((value as f64 / total as f64 * 100.0).min(100.0)) - } -} - fn format_rate(count: u64, elapsed: Duration) -> String { let secs = elapsed.as_secs_f64(); if secs <= f64::EPSILON { - "-".to_owned() + ABSENT.to_owned() } else { format!("{:.2}/s", count as f64 / secs) } } -fn format_count(value: u64) -> String { - value.to_string() -} - fn truncate(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { return value.to_owned(); @@ -2026,14 +1995,10 @@ fn format_seq(value: u32) -> String { value.to_string() } -fn format_optional_u64(value: Option) -> String { - value.map(format_count).unwrap_or_else(|| "-".to_owned()) -} - fn format_optional_hex(value: Option) -> String { value .map(|value| format!("0x{value:x}")) - .unwrap_or_else(|| "-".to_owned()) + .unwrap_or_else(|| ABSENT.to_owned()) } fn duration_ns(value: Duration) -> i128 { @@ -2177,12 +2142,23 @@ mod tests { } } + #[test] + fn spans_stay_coarse_above_a_second_and_share_scalar_units_below_it() { + assert_eq!(format_span(Duration::ZERO), "0s"); + assert_eq!(format_span(Duration::from_nanos(750)), "750ns"); + assert_eq!(format_span(Duration::from_micros(500)), "500.0µs"); + assert_eq!(format_span(Duration::from_millis(25)), "25.0ms"); + assert_eq!(format_span(Duration::from_millis(1_500)), "1.5s"); + assert_eq!(format_span(Duration::from_secs(90)), "1m30s"); + } + #[test] fn formats_signed_durations_and_missing_values() { - assert_eq!(format_ns_i128(Some(-1_500_000)), "-1.5ms"); - assert_eq!(format_ns_i128(Some(750)), "750ns"); - assert_eq!(format_ns_i128(None), "-"); - assert_eq!(format_optional_duration(None), "-"); + assert_eq!(format_optional_ns_i128(Some(-1_500_000)), "-1.5ms"); + assert_eq!(format_optional_ns_i128(Some(750)), "750ns"); + assert_eq!(format_optional_ns_i128(None), "-"); + assert_eq!(format_optional_ns_i128(Some(1_500)), "1.5µs"); + assert_eq!(format_optional_span(None), "-"); assert_eq!(format_duration(Duration::from_millis(25)), "25.0ms"); assert_eq!(format_percent_ratio(1, 4), "25.00%"); assert_eq!(format_optional_hex(Some(0x1f)), "0x1f"); @@ -2748,7 +2724,7 @@ mod tests { assert_eq!( rendered, - vec!["-1.00ms", "-500us", "0.0us", "500us", "1.00ms"] + vec!["-1.00ms", "-500µs", "0.0µs", "500µs", "1.00ms"] ); }