Repository: dathere/qsv (language: rust)

Issue:
`stats`: add `--visualize-whitespace` options
For columns with a String data type, visualize whitespace for the different statistics, e.g.
* replace space with "《_》"
* replace tab with "《→》"
* replace newline with "《¶》"
* etc.

Use `《` (LEFT DOUBLE ANGLE BRACKET (U+300A)) and `》` (RIGHT DOUBLE ANGLE BRACKET (U+300B)) to minimize collisions with "real" data and to make it visually distinctive.

The Rust whitespace characters are defined here - https://doc.rust-lang.org/reference/whitespace.html

This is especially useful for the "minimum" and "mode" statistics for String columns.


Discussion / hints:

Fix patch:
diff --git a/src/cmd/frequency.rs b/src/cmd/frequency.rs
index d5d2bdf28..5a087db8b 100644
--- a/src/cmd/frequency.rs
+++ b/src/cmd/frequency.rs
@@ -99,6 +99,7 @@ frequency options:
                             [default: auto]
    --all-unique-text <arg>  The text to use for the "<ALL_UNIQUE>" category.
                             [default: <ALL_UNIQUE>]
+    --vis-whitespace        Visualize whitespace characters in the output.
     -j, --jobs <arg>        The number of jobs to run in parallel.
                             This works much faster when the given CSV data has
                             an index already created. Note that a file handle
@@ -159,6 +160,7 @@ pub struct Args {
     pub flag_no_headers:      bool,
     pub flag_delimiter:       Option<Delimiter>,
     pub flag_memcheck:        bool,
+    pub flag_vis_whitespace:  bool,
 }
 
 const NULL_VAL: &[u8] = b"(NULL)";
@@ -232,6 +234,8 @@ pub fn run(argv: &[&str]) -> CliResult<()> {
             }
         }
 
+        #[allow(unused_assignments)]
+        let mut value_str = String::with_capacity(100);
         for (value, count, percentage) in sorted_counts {
             pct_decimal = Decimal::from_f64(percentage).unwrap_or_default();
             pct_scale = if args.flag_pct_dec_places < 0 {
@@ -260,7 +264,12 @@ pub fn run(argv: &[&str]) -> CliResult<()> {
             };
             row = vec![
                 &*header_vec,
-                &*value,
+                if args.flag_vis_whitespace {
+                    value_str = util::visualize_whitespace(&String::from_utf8_lossy(&value));
+                    value_str.as_bytes()
+                } else {
+                    &value
+                },
                 itoa_buffer.format(count).as_bytes(),
                 pct_string.as_bytes(),
             ];
diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs
index 88bb2cd19..ef94ee403 100644
--- a/src/cmd/schema.rs
+++ b/src/cmd/schema.rs
@@ -488,6 +488,7 @@ fn get_unique_values(
         flag_no_headers:      args.flag_no_headers,
         flag_delimiter:       args.flag_delimiter,
         flag_memcheck:        args.flag_memcheck,
+        flag_vis_whitespace:  false,
     };
 
     let (headers, ftables) = match freq_args.rconfig().indexed()? {
diff --git a/src/cmd/stats.rs b/src/cmd/stats.rs
index 99912979a..9ef6978e7 100644
--- a/src/cmd/stats.rs
+++ b/src/cmd/stats.rs
@@ -210,6 +210,7 @@ stats options:
                                   file and the stats cache file after the stats run. Otherwise,
                                   the index file and the cache files are kept.
                               [default: 5000]
+    --vis-whitespace          Visualize whitespace characters in the output.
 
 Common options:
     -h, --help             Display this message
@@ -298,6 +299,7 @@ pub struct Args {
     pub flag_no_headers:      bool,
     pub flag_delimiter:       Option<Delimiter>,
     pub flag_memcheck:        bool,
+    pub flag_vis_whitespace:  bool,
 }
 
 // this struct is used to serialize/deserialize the stats to
@@ -803,7 +805,7 @@ pub fn run(argv: &[&str]) -> CliResult<()> {
                 },
             }?;
 
-            let stats_sr_vec = args.stats_to_records(stats);
+            let stats_sr_vec = args.stats_to_records(stats, args.flag_vis_whitespace);
             let mut work_br;
 
             // vec we use to compute dataset-level fingerprint hash
@@ -1085,7 +1087,7 @@ impl Args {
         Ok((headers, merge_all(recv.iter()).unwrap_or_default()))
     }
 
-    fn stats_to_records(&self, stats: Vec<Stats>) -> Vec<csv::StringRecord> {
+    fn stats_to_records(&self, stats: Vec<Stats>, visualize_ws: bool) -> Vec<csv::StringRecord> {
         let round_places = self.flag_round;
         let infer_boolean = self.flag_infer_boolean;
         let mut records = Vec::with_capacity(stats.len());
@@ -1098,7 +1100,7 @@ impl Args {
             pool.execute(move || {
                 // safety: this will only return an Error if the channel has been disconnected
                 // which will not happen in this case
-                send.send(stat.to_record(round_places, infer_boolean))
+                send.send(stat.to_record(round_places, infer_boolean, visualize_ws))
                     .unwrap();
             });
         }
@@ -1558,7 +1560,12 @@ impl Stats {
     }
 
     #[allow(clippy::wrong_self_convention)]
-    pub fn to_record(&mut self, round_places: u32, infer_boolean: bool) -> csv::StringRecord {
+    pub fn to_record(
+        &mut self,
+        round_places: u32,
+        infer_boolean: bool,
+        visualize_ws: bool,
+    ) -> csv::StringRecord {
         // we're doing typesonly and not inferring boolean, just return the type
         if self.which.typesonly && !infer_boolean {
             return csv::StringRecord::from(vec![self.typ.to_string()]);
@@ -1583,7 +1590,7 @@ impl Stats {
         if let Some(mm) = self
             .minmax
             .as_ref()
-            .and_then(|mm| mm.show(typ, round_places))
+            .and_then(|mm| mm.show(typ, round_places, visualize_ws))
         {
             // get first character of min/max values
             minval_lower = mm.0.chars().next().unwrap_or_default().to_ascii_lowercase();
@@ -1660,10 +1667,17 @@ impl Stats {
                             (antimodes_result, antimodes_count, antimode_occurrences),
                         ) = v.modes_antimodes();
                         // mode/s ============
-                        let modes_list = modes_result
-                            .iter()
-                            .map(|c| String::from_utf8_lossy(c))
-                            .join(modes_separator);
+                        let modes_list = if visualize_ws {
+                            modes_result
+                                .iter()
+                                .map(|c| util::visualize_whitespace(&String::from_utf8_lossy(c)))
+                                .join(modes_separator)
+                        } else {
+                            modes_result
+                                .iter()
+                                .map(|c| String::from_utf8_lossy(c))
+                                .join(modes_separator)
+                        };
 
                         // antimode/s ============
                         let antimodes_len = ANTIMODES_LEN.get_or_init(|| {
@@ -1713,7 +1727,11 @@ impl Stats {
                             modes_count.to_string(),
                             mode_occurrences.to_string(),
                             // antimode/s
-                            antimodes_list,
+                            if visualize_ws {
+                                util::visualize_whitespace(&antimodes_list)
+                            } else {
+                                antimodes_list
+                            },
                             antimodes_count.to_string(),
                             antimode_occurrences.to_string(),
                         ]);
@@ -2353,6 +2371,7 @@ impl TypedMinMax {
         &self,
         typ: FieldType,
         round_places: u32,
+        visualize_ws: bool,
     ) -> Option<(String, String, String, String, String)> {
         match typ {
             TNull => None,
@@ -2363,11 +2382,23 @@ impl TypedMinMax {
                     self.strings.sort_order(),
                     self.strings.sortiness(),
                 ) {
-                    let min = String::from_utf8_lossy(min).to_string();
-                    let max = String::from_utf8_lossy(max).to_string();
-                    let sort_order = sort_order.to_string();
-                    let sortiness = util::round_num(sortiness, round_places);
-                    Some((min, max, String::new(), sort_order, sortiness))
+                    let min_str = String::from_utf8_lossy(min).to_string();
+                    let max_str = String::from_utf8_lossy(max).to_string();
+                    let (min_display, max_display) = if visualize_ws {
+                        (
+                            util::visualize_whitespace(&min_str),
+                            util::visualize_whitespace(&max_str),
+                        )
+                    } else {
+                        (min_str, max_str)
+                    };
+                    Some((
+                        min_display,
+                        max_display,
+                        String::new(),
+                        sort_order.to_string(),
+                        util::round_num(sortiness, round_places),
+                    ))
                 } else {
                     None
                 }
diff --git a/src/util.rs b/src/util.rs
index aaeb13422..27d580f29 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -108,6 +108,85 @@ const QSV_POLARS_REV: &str = match option_env!("QSV_POLARS_REV") {
     None => "",
 };
 
+// Add constant for whitespace visualization
+// the whitespace markers as as defined in
+// https://doc.rust-lang.org/reference/whitespace.html
+const WHITESPACE_MARKERS: &[(char, &str)] = &[
+    // common whitespace markers other than space
+    ('\t', "《→》"), // tab
+    ('\n', "《¶》"), // newline
+    ('\r', "《⏎》"), // carriage return
+    // more obscure whitespace markers
+    ('\u{000B}', "《⋮》"), // vertical tab
+    ('\u{000C}', "《␌》"), // form feed
+    ('\u{0009}', "《↹》"), // horizontal tab
+    ('\u{0085}', "《␤》"), // next line
+    ('\u{200E}', "《␎》"), // left-to-right mark
+    ('\u{200F}', "《␏》"), // right-to-left mark
+    ('\u{2028}', "《␊》"), // line separator
+    ('\u{2029}', "《␍》"), // paragraph separator
+    // additional common whitespace markers beyond
+    // https://doc.rust-lang.org/reference/whitespace.html
+    ('\u{00A0}', "《⍽》"),     // non-breaking space
+    ('\u{2003}', "《emsp》"),  // em space
+    ('\u{2007}', "《figsp》"), // figure space
+    ('\u{200B}', "《zwsp》"),  // zero width space
+];
+
+/// Visualizes whitespace characters in a string by replacing them with visible markers
+///
+/// This function takes a string and returns a new string where whitespace characters
+/// are replaced with visible Unicode markers to make them easier to see.
+///
+/// # Arguments
+///
+/// * `s` - The input string to visualize whitespace in
+///
+/// # Returns
+///
+/// A new String with whitespace characters replaced by visible markers
+///
+/// # Behavior
+///
+/// - If the input string contains only spaces, each space is replaced with "《_》"
+/// - For other whitespace characters (tab, newline, etc), uses markers defined in
+///   WHITESPACE_MARKERS
+/// - Non-whitespace characters are left unchanged
+/// - For strings with mixed content, single spaces are preserved as-is
+///
+/// # Examples
+///
+/// ```
+/// let s = "hello\tworld\n";
+/// let vis = visualize_whitespace(s);
+/// assert_eq!(vis, "hello《→》world《¶》");
+///
+/// let spaces = "   ";
+/// let vis = visualize_whitespace(spaces);
+/// assert_eq!(vis, "《_》《_》《_》");
+/// ```
+pub fn visualize_whitespace(s: &str) -> String {
+    // Check if string is all spaces
+    let is_all_spaces = s.chars().all(|c| c == ' ');
+
+    let mut result = String::with_capacity(s.len() * 3);
+    for c in s.chars() {
+        if c == ' ' {
+            if is_all_spaces {
+                // Only use space marker if entire string is spaces
+                result.push_str("《_》");
+            } else {
+                result.push(c);
+            }
+        } else if let Some((_, replacement)) = WHITESPACE_MARKERS.iter().find(|(ws, _)| *ws == c) {
+            result.push_str(replacement);
+        } else {
+            result.push(c);
+        }
+    }
+    result
+}
+
 pub fn qsv_custom_panic() {
     setup_panic!(
         human_panic::Metadata::new(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))
@@ -2075,6 +2154,7 @@ pub fn get_stats_records(
             flag_no_headers:      args.flag_no_headers,
             flag_delimiter:       args.flag_delimiter,
             flag_memcheck:        args.flag_memcheck,
+            flag_vis_whitespace:  false,
         };
 
         let tempfile = tempfile::Builder::new()


Test patch:
diff --git a/tests/test_frequency.rs b/tests/test_frequency.rs
index 97e3ac6bb..7160b434d 100644
--- a/tests/test_frequency.rs
+++ b/tests/test_frequency.rs
@@ -778,3 +778,146 @@ fn assert_eq_ftables(got: &FTables, expected: &FTables) -> bool {
     }
     true
 }
+
+#[test]
+fn frequency_vis_whitespace() {
+    let wrk = Workdir::new("frequency_vis_whitespace");
+
+    // Create test data with various types of whitespace
+    let rows = vec![
+        svec!["header"],
+        svec!["value\t"],       // trailing tab
+        svec!["\tvalue"],       // leading tab
+        svec!["value\r"],       // trailing CR
+        svec!["\rvalue"],       // leading CR
+        svec!["value\n"],       // trailing LF
+        svec!["\nvalue"],       // leading LF
+        svec!["value "],        // trailing space
+        svec![" value"],        // leading space
+        svec!["      "],        // all spaces
+        svec!["value\u{00A0}"], // trailing non-breaking space
+        svec!["\u{00A0}value"], // leading non-breaking space
+        svec!["value\u{2003}"], // trailing em space
+        svec!["\u{2003}value"], // leading em space
+        svec!["value\u{2007}"], // trailing figure space
+        svec!["\u{2007}value"], // leading figure space
+        svec!["value\u{200B}"], // trailing zero width space
+        svec!["\u{200B}value"], // leading zero width space
+        svec!["no_whitespace"],
+    ];
+
+    wrk.create("in.csv", rows);
+
+    let mut cmd = wrk.command("frequency");
+    cmd.arg("in.csv")
+        .args(["--limit", "0"])
+        .arg("--vis-whitespace");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["field", "value", "count", "percentage"],
+        svec!["header", "value", "8", "44.44444"],
+        svec!["header", "(NULL)", "1", "5.55556"],
+        svec!["header", "no_whitespace", "1", "5.55556"],
+        svec!["header", "value《⍽》", "1", "5.55556"],
+        svec!["header", "value《emsp》", "1", "5.55556"],
+        svec!["header", "value《figsp》", "1", "5.55556"],
+        svec!["header", "value《zwsp》", "1", "5.55556"],
+        svec!["header", "《⍽》value", "1", "5.55556"],
+        svec!["header", "《emsp》value", "1", "5.55556"],
+        svec!["header", "《figsp》value", "1", "5.55556"],
+        svec!["header", "《zwsp》value", "1", "5.55556"],
+    ];
+
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn frequency_vis_whitespace_no_trim() {
+    let wrk = Workdir::new("frequency_vis_whitespace_no_trim");
+
+    // Create test data with multiple occurrences of same whitespace patterns
+    let rows = vec![
+        svec!["header"],
+        svec!["value\t"], // trailing tab
+        svec!["value\t"], // trailing tab (duplicate)
+        svec!["\tvalue"], // leading tab
+        svec!["\tvalue"], // leading tab (duplicate)
+        svec!["value "],  // trailing space
+        svec!["value "],  // trailing space (duplicate)
+        svec![" value"],  // leading space
+        svec![" value"],  // leading space (duplicate)
+        svec!["      "],  // all spaces
+        svec!["      "],  // all spaces (duplicate)
+        svec!["no_whitespace"],
+    ];
+
+    wrk.create("in.csv", rows);
+
+    let mut cmd = wrk.command("frequency");
+    cmd.arg("in.csv")
+        .args(["--limit", "0"])
+        .arg("--vis-whitespace")
+        .arg("--no-trim");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["field", "value", "count", "percentage"],
+        svec!["header", "《→》value", "2", "18.18182"],
+        svec!["header", "《_》《_》《_》《_》《_》《_》", "2", "18.18182"],
+        svec!["header", " value", "2", "18.18182"],
+        svec!["header", "value《→》", "2", "18.18182"],
+        svec!["header", "value ", "2", "18.18182"],
+        svec!["header", "no_whitespace", "1", "9.09091"],
+    ];
+
+    assert_eq!(got, expected);
+}
+
+#[test]
+fn frequency_vis_whitespace_ignore_case() {
+    let wrk = Workdir::new("frequency_vis_whitespace_ignore_case");
+
+    // Create test data with whitespace and mixed case
+    let rows = vec![
+        svec!["header"],
+        svec!["Value\t"],       // trailing tab
+        svec!["\tVALUE"],       // leading tab
+        svec!["value "],        // trailing space
+        svec!["value\u{000B}"], // vertical tab
+        svec!["value\u{000C}"], // form feed
+        svec!["value\u{0085}"], // next line
+        svec!["value\u{200E}"], // left-to-right mark
+        svec!["value\u{200F}"], // right-to-left mark
+        svec!["value\u{2028}"], // line separator
+        svec!["value\u{2029}"], // paragraph separator
+        svec!["value\u{00A0}"], // non-breaking space
+        svec!["value\u{2003}"], // em space
+        svec!["value\u{2007}"], // figure space
+        svec!["value\u{200B}"], // zero width space
+        svec![" VALUE"],        // leading space
+        svec!["no_whitespace"],
+        svec!["      "], // all spaces
+    ];
+
+    wrk.create("in.csv", rows);
+
+    let mut cmd = wrk.command("frequency");
+    cmd.arg("in.csv")
+        .args(["--limit", "0"])
+        .arg("--vis-whitespace")
+        .arg("--ignore-case");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+    let expected = vec![
+        svec!["field", "value", "count", "percentage"],
+        svec!["header", "value", "12", "70.58824"],
+        svec!["header", "(NULL)", "1", "5.88235"],
+        svec!["header", "no_whitespace", "1", "5.88235"],
+        svec!["header", "value《zwsp》", "1", "5.88235"],
+        svec!["header", "value《␎》", "1", "5.88235"],
+        svec!["header", "value《␏》", "1", "5.88235"],
+    ];
+
+    assert_eq!(got, expected);
+}
diff --git a/tests/test_stats.rs b/tests/test_stats.rs
index fd87c8901..0109bf64c 100644
--- a/tests/test_stats.rs
+++ b/tests/test_stats.rs
@@ -1626,3 +1626,413 @@ mod stats_header_fields {
     stats_test_headers!(stats_header_field_name, "field", &["a"], "header");
     stats_test_no_headers!(stats_header_no_field_name, "field", &["a"], "0");
 }
+
+#[test]
+fn stats_vis_whitespace() {
+    let wrk = Workdir::new("stats_vis_whitespace");
+
+    // Create test data with various types of whitespace
+    wrk.create(
+        "data.csv",
+        vec![
+            svec!["col1", "col2", "col3"],
+            svec!["value\t", "\tvalue", "\tvalue\t"], // Tabs
+            svec!["value\r", "\rvalue", "\rvalue\r"], // Carriage returns
+            svec!["value\n", "\nvalue", "\nvalue\n"], // Line feeds
+            svec!["value\n", "\nvalue", "\nvalue\n"], // Line feeds repeat
+            svec!["no_whitespace", "also_none", "clean"],
+            svec![
+                "the spaces in this field are visible as normal spaces",
+                "    ",
+                "the trailing spaces are left alone   "
+            ],
+            svec![
+                "z obscure whitespace \u{000B} \u{000C} \u{0009} \u{0085} \u{200E} \u{200F} \
+                 \u{2028} \u{2029} are also visible",
+                "",
+                "          "
+            ],
+        ],
+    );
+
+    let mut cmd = wrk.command("stats");
+    cmd.arg("--vis-whitespace")
+        .arg("--everything")
+        .arg("data.csv");
+
+    let got: Vec<Vec<String>> = wrk.read_stdout(&mut cmd);
+
+    // Create expected output with visualized whitespace using the exact markers
+    let expected = vec![
+        svec![
+            "field",
+            "type",
+            "is_ascii",
+            "sum",
+            "min",
+            "max",
+            "range",
+            "sort_order",
+            "sortiness",
+            "min_length",
+            "max_length",
+            "sum_length",
+            "avg_length",
+            "stddev_length",
+            "variance_length",
+            "cv_length",
+            "mean",
+            "sem",
+            "geometric_mean",
+            "harmonic_mean",
+            "stddev",
+            "variance",
+            "cv",
+            "nullcount",
+            "max_precision",
+            "sparsity",
+            "mad",
+            "lower_outer_fence",
+            "lower_inner_fence",
+            "q1",
+            "q2_median",
+            "q3",
+            "iqr",
+            "upper_inner_fence",
+            "upper_outer_fence",
+            "skewness",
+            "cardinality",
+            "mode",
+            "mode_count",
+            "mode_occurrences",
+            "antimode",
+            "antimode_count",
+            "antimode_occurrences",
+            "qsv__value"
+        ],
+        svec![
+            "col1",
+            "String",
+            "false",
+            "",
+            "no_whitespace",
+            r#"z obscure whitespace 《⋮》 《␌》 《→》 《␤》 《␎》 《␏》 《␊》 《␍》 are also visible"#,
+            "",
+            "Unsorted",
+            "0.3333",
+            "6",
+            "62",
+            "152",
+            "21.7143",
+            "22.883",
+            "523.6327",
+            "1.0538",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "0",
+            "",
+            "0",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "6",
+            "value《¶》",
+            "1",
+            "2",
+            r#"no_whitespace|the spaces in this field are visible as normal spaces|value《→》|value《⏎》|z obscure whitespa..."#,
+            "5",
+            "1",
+            ""
+        ],
+        svec![
+            "col2",
+            "String",
+            "true",
+            "",
+            "《→》value",
+            "also_none",
+            "",
+            "Unsorted",
+            "0.2",
+            "0",
+            "9",
+            "37",
+            "5.2857",
+            "2.5475",
+            "6.4898",
+            "0.482",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "1",
+            "",
+            "0.1429",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "6",
+            "《¶》value",
+            "1",
+            "2",
+            r#"NULL|《→》value|《⏎》value|    |also_none"#,
+            "5",
+            "1",
+            ""
+        ],
+        svec![
+            "col3",
+            "String",
+            "true",
+            "",
+            "《→》value《→》",
+            "the trailing spaces are left alone   ",
+            "",
+            "Unsorted",
+            "0.3333",
+            "5",
+            "37",
+            "80",
+            "11.4286",
+            "10.5269",
+            "110.8163",
+            "0.9211",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "0",
+            "",
+            "0",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "6",
+            "《¶》value《¶》",
+            "1",
+            "2",
+            r#"《→》value《→》|《⏎》value《⏎》|          |clean|the trailing spaces are left alone   "#,
+            "5",
+            "1",
+            ""
+        ],
+        svec![
+            "qsv__rowcount",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "7"
+        ],
+        svec![
+            "qsv__columncount",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "3"
+        ],
+        svec![
+            "qsv__filesize_bytes",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "323"
+        ],
+        svec![
+            "qsv__fingerprint_hash",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "",
+            "39f84fb60ced1488a91fc3a7a6e22692dafb414e0d43f09ab768c5f9981db630"
+        ],
+    ];
+
+    assert_eq!(got, expected);
+}