Skip to main content

stuffr_cli/bench/
report.rs

1//! Output renderers for `stuffr bench`: the human table (layout B), JSON and
2//! CSV. Pure formatting over already-measured rows; no timing happens here.
3//!
4//! The size formatter is local: `stuffr_core::format_size` prints exact
5//! multiples without a decimal and uses `bytes` as the unit, while the table
6//! wants a uniform one-decimal `38.1 MiB` and `512 B`.
7
8use std::io::{self, Write};
9
10use serde_json::{Value, json};
11
12use super::measure::Mode;
13use super::score::{Measured, Outcome, Row, balance_scores, winners};
14
15/// The conditions the run was made under, shown on the input line and in
16/// JSON and CSV. The backend is per row (`Row::backend`), not here: one
17/// build can bench some codecs through C and others in pure Rust.
18#[derive(Clone, Debug)]
19pub struct Conditions {
20    pub files: usize,
21    pub bytes_in: u64,
22    pub mode: Mode,
23    /// `--threads` as given.
24    pub threads: Option<usize>,
25    /// The worker budget that setting resolved to (1 without `--threads`).
26    pub workers: usize,
27    pub repeat: u32,
28    pub streamed: bool,
29}
30
31/// The winners block's label column: the longest label plus two spaces.
32/// Padded by format rather than spelled with spaces in the literal, which
33/// the workspace's collapsed-indentation guard would flag.
34const LABEL: usize = 21;
35
36const HEADER: [&str; 6] = ["format", "level", "size", "ratio", "compress", "decompress"];
37
38/// `512 B`, else one decimal of the largest unit up to GiB. The unit is
39/// chosen on the rounded value, so 1023.96 KiB reads `1.0 MiB`, never
40/// `1024.0 KiB`.
41fn size_text(bytes: u64) -> String {
42    if bytes < 1024 {
43        return format!("{bytes} B");
44    }
45    const UNITS: [&str; 3] = ["KiB", "MiB", "GiB"];
46    let mut tenths = 0u128;
47    for (i, name) in UNITS.iter().enumerate() {
48        let unit = 1u128 << (10 * (i + 1));
49        tenths = (u128::from(bytes) * 10 + unit / 2) / unit;
50        if tenths < 10_240 || i == UNITS.len() - 1 {
51            return format!("{}.{} {name}", tenths / 10, tenths % 10);
52        }
53    }
54    unreachable!("the last unit always returns ({tenths})")
55}
56
57/// `bytes_in / bytes_out`, `None` when either is 0.
58fn ratio_of(bytes_in: u64, bytes_out: u64) -> Option<f64> {
59    (bytes_in > 0 && bytes_out > 0).then(|| bytes_in as f64 / bytes_out as f64)
60}
61
62fn ratio_text(bytes_in: u64, bytes_out: u64) -> String {
63    ratio_of(bytes_in, bytes_out).map_or_else(|| "-".into(), |r| format!("{r:.1}x"))
64}
65
66/// `0.4 ms` below 10 ms, `850 ms` below one second, otherwise `1.3 s`:
67/// rounded half up, in integers, and each form chosen on its rounded value,
68/// so 9.96 ms reads `10 ms` and 999.6 ms reads `1.0 s`.
69fn time_text(ns: u64) -> String {
70    let ns = u128::from(ns);
71    let tenth_ms = (ns + 50_000) / 100_000;
72    if tenth_ms < 100 {
73        return format!("{}.{} ms", tenth_ms / 10, tenth_ms % 10);
74    }
75    let ms = (ns + 500_000) / 1_000_000;
76    if ms < 1000 {
77        return format!("{ms} ms");
78    }
79    let tenths = (ns + 50_000_000) / 100_000_000;
80    format!("{}.{} s", tenths / 10, tenths % 10)
81}
82
83/// A failure reason fit for one table line: its first line, any other
84/// control character escaped, and `…` when lines were dropped. JSON and CSV
85/// keep the whole text.
86fn one_line(why: &str) -> String {
87    let mut lines = why.lines();
88    let first = lines.next().unwrap_or("");
89    let mut out = String::with_capacity(first.len());
90    for c in first.chars() {
91        if c.is_control() {
92            out.extend(c.escape_default());
93        } else {
94            out.push(c);
95        }
96    }
97    if lines.next().is_some() {
98        out.push_str(" …");
99    }
100    out
101}
102
103/// The input line's backend: `pure` when every row ran in pure Rust, else
104/// `C: ` and the codecs that ran through a C library, in table order.
105fn backend_text(rows: &[Row]) -> String {
106    let mut c: Vec<&str> = Vec::new();
107    for r in rows.iter().filter(|r| r.backend == "c") {
108        if !c.contains(&r.format) {
109            c.push(r.format);
110        }
111    }
112    if c.is_empty() {
113        "pure".into()
114    } else {
115        format!("C: {}", c.join(", "))
116    }
117}
118
119fn mode_name(mode: Mode) -> &'static str {
120    match mode {
121        Mode::OneStream => "one-stream",
122        Mode::PerFile => "per-file",
123    }
124}
125
126fn name_of(row: &Row) -> String {
127    match row.level {
128        Some(l) => format!("{} -{l}", row.format),
129        None => row.format.to_string(),
130    }
131}
132
133/// The resolved compression worker budget; `(auto)` when `--threads 0`
134/// chose it. Decompression is always single-threaded.
135fn threads_text(threads: Option<usize>, workers: usize) -> String {
136    let unit = if workers == 1 {
137        "compress thread"
138    } else {
139        "compress threads"
140    };
141    match threads {
142        Some(0) => format!("{workers} {unit} (auto)"),
143        _ => format!("{workers} {unit}"),
144    }
145}
146
147fn input_line(c: &Conditions, rows: &[Row]) -> String {
148    let mode = match c.mode {
149        Mode::OneStream => "one stream",
150        Mode::PerFile => "per file",
151    };
152    let files = if c.files == 1 { "file" } else { "files" };
153    let runs = if c.repeat == 1 { "run" } else { "runs" };
154    format!(
155        "input: {} {files}, {} · {mode} · {} · {} · {} {runs}{}",
156        c.files,
157        size_text(c.bytes_in),
158        threads_text(c.threads, c.workers),
159        backend_text(rows),
160        c.repeat,
161        if c.streamed {
162            ", streamed from disk"
163        } else {
164            ""
165        },
166    )
167}
168
169fn table_lines(rows: &[Row], bytes_in: u64) -> Vec<String> {
170    let mut cells: Vec<Vec<String>> = vec![HEADER.iter().map(|s| s.to_string()).collect()];
171    let mut failures: Vec<Option<String>> = vec![None];
172    for row in rows {
173        let level = row.level.map_or_else(|| "-".into(), |l| l.to_string());
174        match &row.outcome {
175            Outcome::Ok(m) => {
176                cells.push(vec![
177                    row.format.into(),
178                    level,
179                    size_text(m.bytes_out),
180                    ratio_text(bytes_in, m.bytes_out),
181                    time_text(m.compress_ns),
182                    time_text(m.decompress_ns),
183                ]);
184                failures.push(None);
185            }
186            Outcome::Failed(why) => {
187                cells.push(vec![row.format.into(), level]);
188                failures.push(Some(format!("failed: {}", one_line(why))));
189            }
190        }
191    }
192    let widths: Vec<usize> = (0..6)
193        .map(|i| {
194            cells
195                .iter()
196                .filter_map(|r| r.get(i))
197                .map(|s| s.chars().count())
198                .max()
199                .unwrap_or(0)
200        })
201        .collect();
202    cells
203        .iter()
204        .zip(&failures)
205        .map(|(r, failed)| {
206            let mut line = format!("{:<w$}", r[0], w = widths[0]);
207            for (i, cell) in r.iter().enumerate().skip(1) {
208                line.push_str(&format!("  {:>w$}", cell, w = widths[i]));
209            }
210            if let Some(f) = failed {
211                line.push_str("  ");
212                line.push_str(f);
213            }
214            line
215        })
216        .collect()
217}
218
219fn write_table(rows: &[Row], bytes_in: u64, out: &mut dyn Write) -> io::Result<()> {
220    for line in table_lines(rows, bytes_in) {
221        writeln!(out, "{}", line.trim_end())?;
222    }
223    Ok(())
224}
225
226fn measured(row: &Row) -> Option<&Measured> {
227    match &row.outcome {
228        Outcome::Ok(m) => Some(m),
229        Outcome::Failed(_) => None,
230    }
231}
232
233/// The input line, the table and the winners block.
234pub fn human(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
235    writeln!(out, "{}", input_line(c, rows))?;
236    writeln!(out)?;
237    write_table(rows, c.bytes_in, out)?;
238    writeln!(out)?;
239    let w = winners(rows);
240    let pick = |i: Option<usize>| i.map(|i| (name_of(&rows[i]), measured(&rows[i]).copied()));
241    let (Some((sn, Some(sm))), Some((cn, Some(cm))), Some((dn, Some(dm))), Some((bn, Some(bm)))) = (
242        pick(w.smallest),
243        pick(w.fastest_compress),
244        pick(w.fastest_decompress),
245        pick(w.balance),
246    ) else {
247        return writeln!(out, "no format succeeded");
248    };
249    let width = [&sn, &cn, &dn, &bn]
250        .iter()
251        .map(|s| s.chars().count() + 2)
252        .max()
253        .unwrap_or(0)
254        .max(12);
255    writeln!(
256        out,
257        "{:<LABEL$}{sn:<width$}{} ({})",
258        "smallest:",
259        size_text(sm.bytes_out),
260        ratio_text(c.bytes_in, sm.bytes_out)
261    )?;
262    writeln!(
263        out,
264        "{:<LABEL$}{cn:<width$}{}",
265        "fastest compress:",
266        time_text(cm.compress_ns)
267    )?;
268    writeln!(
269        out,
270        "{:<LABEL$}{dn:<width$}{}",
271        "fastest decompress:",
272        time_text(dm.decompress_ns)
273    )?;
274    writeln!(
275        out,
276        "{:<LABEL$}{bn:<width$}{} at {} / {}",
277        "best balance:",
278        ratio_text(c.bytes_in, bm.bytes_out),
279        time_text(bm.compress_ns),
280        time_text(bm.decompress_ns)
281    )
282}
283
284/// The table alone: header and rows.
285pub fn table_only(rows: &[Row], bytes_in: u64, out: &mut dyn Write) -> io::Result<()> {
286    write_table(rows, bytes_in, out)
287}
288
289fn finite(x: Option<f64>) -> Value {
290    x.filter(|x| x.is_finite())
291        .map_or(Value::Null, |x| json!(x))
292}
293
294/// One JSON object: conditions, rows and winners. Scores are relative to
295/// this run.
296pub fn json(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
297    let scores = balance_scores(rows);
298    let w = winners(rows);
299    let who = |i: Option<usize>| {
300        i.map_or(
301            Value::Null,
302            |i| json!({"format": rows[i].format, "level": rows[i].level}),
303        )
304    };
305    let jrows: Vec<Value> = rows
306        .iter()
307        .zip(&scores)
308        .map(|(r, score)| match &r.outcome {
309            Outcome::Ok(m) => json!({
310                "format": r.format, "level": r.level, "backend": r.backend, "ok": true,
311                "bytes_out": m.bytes_out,
312                "ratio": finite(ratio_of(c.bytes_in, m.bytes_out)),
313                "compress_ns": m.compress_ns, "decompress_ns": m.decompress_ns,
314                "balance": finite(*score), "error": null,
315            }),
316            Outcome::Failed(why) => json!({
317                "format": r.format, "level": r.level, "backend": r.backend, "ok": false,
318                "bytes_out": null, "ratio": null,
319                "compress_ns": null, "decompress_ns": null,
320                "balance": null, "error": why,
321            }),
322        })
323        .collect();
324    let doc = json!({
325        "conditions": {
326            "files": c.files,
327            "bytes_in": c.bytes_in,
328            "mode": mode_name(c.mode),
329            "threads": c.threads,
330            "workers": c.workers,
331            "repeat": c.repeat,
332            "streamed": c.streamed,
333        },
334        "rows": jrows,
335        "winners": {
336            "smallest": who(w.smallest),
337            "fastest_compress": who(w.fastest_compress),
338            "fastest_decompress": who(w.fastest_decompress),
339            "balance": who(w.balance),
340        },
341    });
342    writeln!(out, "{doc}")
343}
344
345fn csv_field(s: &str) -> String {
346    if s.contains([',', '"', '\n', '\r']) {
347        format!("\"{}\"", s.replace('"', "\"\""))
348    } else {
349        s.to_string()
350    }
351}
352
353/// Header plus one line per row; empty fields stand for none. The run's
354/// conditions follow the measurements on every line, and the row's backend
355/// last.
356pub fn csv(c: &Conditions, rows: &[Row], out: &mut dyn Write) -> io::Result<()> {
357    writeln!(
358        out,
359        "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
360         files,mode,threads,workers,repeat,streamed,backend"
361    )?;
362    let bytes_in = c.bytes_in;
363    let threads = c.threads.map_or(String::new(), |t| t.to_string());
364    let conditions = format!(
365        "{},{},{threads},{},{},{}",
366        c.files,
367        mode_name(c.mode),
368        c.workers,
369        c.repeat,
370        c.streamed
371    );
372    for (r, score) in rows.iter().zip(balance_scores(rows)) {
373        let level = r.level.map_or(String::new(), |l| l.to_string());
374        match &r.outcome {
375            Outcome::Ok(m) => {
376                let ratio =
377                    ratio_of(bytes_in, m.bytes_out).map_or(String::new(), |x| x.to_string());
378                let bal = score.map_or(String::new(), |x| x.to_string());
379                writeln!(
380                    out,
381                    "{},{level},true,{bytes_in},{},{ratio},{},{},{bal},,{conditions},{}",
382                    r.format, m.bytes_out, m.compress_ns, m.decompress_ns, r.backend
383                )?;
384            }
385            Outcome::Failed(why) => writeln!(
386                out,
387                "{},{level},false,{bytes_in},,,,,,{},{conditions},{}",
388                r.format,
389                csv_field(why),
390                r.backend
391            )?,
392        }
393    }
394    Ok(())
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    const MIB: u64 = 1 << 20;
402
403    fn ok(format: &'static str, level: Option<i32>, out: u64, c_ms: u64, d_ms: u64) -> Row {
404        Row {
405            format,
406            level,
407            backend: "pure",
408            outcome: Outcome::Ok(Measured {
409                bytes_out: out,
410                compress_ns: c_ms * 1_000_000,
411                decompress_ns: d_ms * 1_000_000,
412            }),
413        }
414    }
415
416    fn rows() -> Vec<Row> {
417        vec![
418            ok("zstd", Some(3), 40 * MIB, 900, 300),
419            ok("lz4", None, 100 * MIB, 600, 200),
420            ok("xz", Some(6), 25 * MIB, 8200, 1250),
421            Row {
422                format: "bzip2",
423                level: Some(9),
424                backend: "pure",
425                outcome: Outcome::Failed("no c tier".into()),
426            },
427        ]
428    }
429
430    fn cond() -> Conditions {
431        Conditions {
432            files: 3,
433            bytes_in: 412 * MIB,
434            mode: Mode::OneStream,
435            threads: None,
436            workers: 1,
437            repeat: 1,
438            streamed: false,
439        }
440    }
441
442    fn render(f: impl FnOnce(&mut dyn Write) -> io::Result<()>) -> String {
443        let mut v = Vec::new();
444        f(&mut v).unwrap();
445        String::from_utf8(v).unwrap()
446    }
447
448    #[test]
449    fn human_output_is_layout_b() {
450        let got = render(|o| human(&cond(), &rows(), o));
451        // A file, not a literal: the table's column runs would trip the
452        // workspace's collapsed-indentation guard, which scans `.rs` files.
453        let want = include_str!("testdata/human-layout-b.txt");
454        assert_eq!(got, want);
455    }
456
457    #[test]
458    fn table_only_has_no_input_line_or_winners() {
459        let got = render(|o| table_only(&rows(), 412 * MIB, o));
460        assert!(got.starts_with("format"));
461        assert!(!got.contains("input:") && !got.contains("smallest"));
462        assert_eq!(got.lines().count(), 5);
463    }
464
465    #[test]
466    fn json_parses_and_carries_conditions() {
467        let got = render(|o| json(&cond(), &rows(), o));
468        let v: Value = serde_json::from_str(&got).unwrap();
469        assert_eq!(v["conditions"]["mode"], "one-stream");
470        assert!(v["conditions"]["tier"].is_null(), "the backend is per row");
471        assert_eq!(v["rows"][0]["backend"], "pure");
472        assert_eq!(v["rows"].as_array().unwrap().len(), 4);
473        assert_eq!(v["winners"]["smallest"]["format"], "xz");
474        assert_eq!(v["rows"][3]["ok"], false);
475        assert!(!v["rows"][3]["error"].is_null());
476        assert!(v["rows"][1]["level"].is_null());
477    }
478
479    #[test]
480    fn csv_quotes_a_comma_in_an_error() {
481        let rows = vec![Row {
482            format: "xz",
483            level: Some(6),
484            backend: "c",
485            outcome: Outcome::Failed("bad, \"very\" bad".into()),
486        }];
487        let c = Conditions {
488            bytes_in: 10,
489            ..cond()
490        };
491        let got = render(|o| csv(&c, &rows, o));
492        let mut lines = got.lines();
493        assert_eq!(
494            lines.next().unwrap(),
495            "format,level,ok,bytes_in,bytes_out,ratio,compress_ns,decompress_ns,balance,error,\
496             files,mode,threads,workers,repeat,streamed,backend"
497        );
498        assert_eq!(
499            lines.next().unwrap(),
500            "xz,6,false,10,,,,,,\"bad, \"\"very\"\" bad\",3,one-stream,,1,1,false,c"
501        );
502    }
503
504    #[test]
505    fn zero_input_prints_a_dash_ratio() {
506        let rows = vec![ok("lz4", None, 0, 1, 1)];
507        let got = render(|o| table_only(&rows, 0, o));
508        assert!(got.lines().nth(1).unwrap().contains("  -  "), "{got}");
509        let mut c = cond();
510        c.bytes_in = 0;
511        let j = render(|o| json(&c, &rows, o));
512        assert!(serde_json::from_str::<Value>(&j).unwrap()["rows"][0]["ratio"].is_null());
513    }
514
515    #[test]
516    fn no_success_prints_no_format_succeeded() {
517        let rows = vec![Row {
518            format: "xz",
519            level: Some(1),
520            backend: "pure",
521            outcome: Outcome::Failed("x".into()),
522        }];
523        let got = render(|o| human(&cond(), &rows, o));
524        assert!(got.ends_with("\nno format succeeded\n"), "{got}");
525        assert!(!got.contains("smallest"));
526    }
527
528    #[test]
529    fn the_input_line_shows_the_resolved_worker_count() {
530        assert_eq!(threads_text(None, 1), "1 compress thread");
531        assert_eq!(threads_text(Some(1), 1), "1 compress thread");
532        assert_eq!(threads_text(Some(4), 4), "4 compress threads");
533        assert_eq!(threads_text(Some(0), 8), "8 compress threads (auto)");
534        let c = Conditions {
535            threads: Some(0),
536            workers: 8,
537            ..cond()
538        };
539        assert!(input_line(&c, &rows()).contains(" · 8 compress threads (auto) · "));
540        let j = render(|o| json(&c, &[], o));
541        let v: Value = serde_json::from_str(&j).unwrap();
542        assert_eq!(v["conditions"]["threads"], 0);
543        assert_eq!(v["conditions"]["workers"], 8);
544    }
545
546    #[test]
547    fn ms_below_a_second_s_above() {
548        assert_eq!(time_text(850_000_000), "850 ms");
549        assert_eq!(time_text(1_250_000_000), "1.3 s");
550        assert_eq!(time_text(900_000_000), "900 ms");
551    }
552
553    #[test]
554    fn csv_carries_the_conditions_and_each_rows_backend() {
555        let mut rows = rows();
556        rows[0].backend = "c";
557        let c = Conditions {
558            mode: Mode::PerFile,
559            threads: Some(0),
560            workers: 8,
561            repeat: 5,
562            streamed: true,
563            ..cond()
564        };
565        let got = render(|o| csv(&c, &rows, o));
566        let lines: Vec<&str> = got.lines().collect();
567        let header: Vec<&str> = lines[0].split(',').collect();
568        assert_eq!(header.len(), 17);
569        assert_eq!(
570            &header[..10],
571            &[
572                "format",
573                "level",
574                "ok",
575                "bytes_in",
576                "bytes_out",
577                "ratio",
578                "compress_ns",
579                "decompress_ns",
580                "balance",
581                "error"
582            ]
583        );
584        for line in &lines[1..] {
585            let f: Vec<&str> = line.split(',').collect();
586            assert_eq!(f.len(), 17, "{line}");
587            assert_eq!(
588                &f[10..16],
589                &["3", "per-file", "0", "8", "5", "true"],
590                "{line}"
591            );
592        }
593        assert!(lines[1].starts_with("zstd,3,true,"), "{}", lines[1]);
594        assert!(lines[1].ends_with(",c"), "{}", lines[1]);
595        assert!(lines[2].ends_with(",pure"), "{}", lines[2]);
596        // No --threads: an empty field, not 0.
597        let got = render(|o| csv(&cond(), &rows, o));
598        assert!(
599            got.lines()
600                .nth(1)
601                .unwrap()
602                .contains(",3,one-stream,,1,1,false,")
603        );
604    }
605
606    #[test]
607    fn the_input_line_names_the_c_backed_codecs() {
608        let mut rows = rows();
609        assert!(input_line(&cond(), &rows).contains(" · pure · "));
610        rows[0].backend = "c";
611        rows[1].backend = "c";
612        rows.push(ok("zstd", Some(19), MIB, 1, 1));
613        rows.last_mut().unwrap().backend = "c";
614        let line = input_line(&cond(), &rows);
615        assert!(line.contains(" · C: zstd, lz4 · "), "{line}");
616        let j = render(|o| json(&cond(), &rows, o));
617        let v: Value = serde_json::from_str(&j).unwrap();
618        let backends: Vec<&str> = v["rows"]
619            .as_array()
620            .unwrap()
621            .iter()
622            .map(|r| r["backend"].as_str().unwrap())
623            .collect();
624        assert_eq!(backends, ["c", "c", "pure", "pure", "c"]);
625    }
626
627    #[test]
628    fn a_multi_line_failure_keeps_to_one_table_line() {
629        let mut rows = rows();
630        rows[3].outcome = Outcome::Failed("first\nsecond\tthird\u{1b}[31m".into());
631        let table = render(|o| table_only(&rows, 412 * MIB, o));
632        assert_eq!(table.lines().count(), 5, "{table}");
633        let last = table.lines().last().unwrap();
634        assert!(last.ends_with("failed: first …"), "{last}");
635        rows[3].outcome = Outcome::Failed("tab\there\u{1b}[31m".into());
636        let table = render(|o| table_only(&rows, 412 * MIB, o));
637        let last = table.lines().last().unwrap();
638        assert!(last.ends_with("failed: tab\\there\\u{1b}[31m"), "{last}");
639        // JSON and CSV keep the whole text.
640        let j = render(|o| json(&cond(), &rows, o));
641        let v: Value = serde_json::from_str(&j).unwrap();
642        assert_eq!(v["rows"][3]["error"], "tab\there\u{1b}[31m");
643    }
644
645    #[test]
646    fn times_under_10_ms_keep_a_decimal_and_edges_round_up_a_unit() {
647        assert_eq!(time_text(40_000), "0.0 ms");
648        assert_eq!(time_text(400_000), "0.4 ms");
649        assert_eq!(time_text(9_940_000), "9.9 ms");
650        // 9.96 ms rounds to 10.0, so it takes the whole-ms form.
651        assert_eq!(time_text(9_960_000), "10 ms");
652        assert_eq!(time_text(10_000_000), "10 ms");
653        // 999.6 ms rounds to 1000 ms: a second.
654        assert_eq!(time_text(999_600_000), "1.0 s");
655        assert_eq!(time_text(999_400_000), "999 ms");
656        assert_eq!(time_text(59_950_000_000), "60.0 s");
657    }
658
659    #[test]
660    fn sizes_round_up_a_unit_at_the_edge() {
661        assert_eq!(size_text(1023), "1023 B");
662        assert_eq!(size_text(1024), "1.0 KiB");
663        // 1023.96 KiB and MiB print as 1024.0 of the unit below otherwise.
664        assert_eq!(size_text(1_048_535), "1.0 MiB");
665        assert_eq!(size_text((1023.96 * MIB as f64) as u64), "1.0 GiB");
666        assert_eq!(size_text((1023.9 * MIB as f64) as u64), "1023.9 MiB");
667        assert_eq!(size_text(2 << 30), "2.0 GiB");
668        assert_eq!(size_text(412 * MIB), "412.0 MiB");
669    }
670}