diff --git a/CHANGELOG.md b/CHANGELOG.md index e50fc5b..018c05d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org). +## [1.10.0] - 2026-06-25 + + - Nested TDF definitions inherit the parent field name in the CSV header column + - Variable length array structs as the last element in a TDF generate a new row per instance + - Handle 0 length trailing VLAs + ## [1.9.0] - 2026-06-16 - New output format [Apache Parquet](https://parquet.apache.org/) diff --git a/Cargo.toml b/Cargo.toml index b044fac..ba80f87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "infuse_decoder" -version = "1.9.0" +version = "1.10.0" edition = "2024" [[bin]] diff --git a/scripts/tdf_decoder.rs.jinja b/scripts/tdf_decoder.rs.jinja index 3cb2e3c..db8fd67 100644 --- a/scripts/tdf_decoder.rs.jinja +++ b/scripts/tdf_decoder.rs.jinja @@ -14,7 +14,7 @@ pub fn vla_bytes_remaining(cursor: &mut Cursor<&[u8]>, cursor_start: u64, size: { let cursor_current = cursor.position(); let cursor_read = cursor_current - cursor_start; - if cursor_read >= size as u64 { + if cursor_read > size as u64 { return Result::Err(Error::new( ErrorKind::InvalidData, "Insufficient data remaining", diff --git a/scripts/tdf_decoder_build.py b/scripts/tdf_decoder_build.py index f047292..1cab897 100644 --- a/scripts/tdf_decoder_build.py +++ b/scripts/tdf_decoder_build.py @@ -178,7 +178,7 @@ def decoders_gen(tdf_defs, output): for _tdf_id, info in tdf_defs["definitions"].items(): info["arrow_schema"] = arrow_schema_expr(info, tdf_defs["structs"]) - def field_conv_func(field, name_prefix=None): + def field_conv_func(field, name_prefix=None, variable_item=False): t = rust_type[field["type"]] func = f"cursor.read_{t[0]}" if t[1]: @@ -229,6 +229,8 @@ def field_conv_func(field, name_prefix=None): ] else: if field["num"] == 0: + if variable_item: + return [(n, func)] return [ (n, "tdf_field_read_vla_to_str(cursor, cursor_start, size)?") ] @@ -256,27 +258,101 @@ def field_fmt(field): structs = {} struct_fmts = {} for name, struct in tdf_defs["structs"].items(): - funcs = [] fmts = [] for f in struct["fields"]: - funcs += field_conv_func(f) fmts += field_fmt(f) - structs[f"struct {name}"] = funcs + structs[f"struct {name}"] = struct["fields"] struct_fmts[f"struct {name}"] = fmts + def csv_flatten_field(field, convs, fmt, name_prefix=None, variable_item=False): + if field["type"] in structs: + for struct_field in structs[field["type"]]: + convs += field_conv_func( + struct_field, + name_prefix=field["name"] if name_prefix is None else f"{name_prefix}.{field['name']}", + variable_item=variable_item, + ) + fmt += struct_fmts[field["type"]] + elif field["type"] in rust_type: + convs += field_conv_func(field, name_prefix, variable_item) + fmt_field = ( + {k: v for k, v in field.items() if k != "num"} + if variable_item and field.get("num", None) == 0 + else field + ) + fmt += field_fmt(fmt_field) + else: + raise RuntimeError(f"Bad type '{field['type']}'") + + def csv_field_byte_size(field, repeated_item=False): + c_type = field["type"] + conv = field.get("conversion", {}) + if "int" in conv: + return field["num"] + if c_type in structs: + base = sum(csv_field_byte_size(child) for child in structs[c_type]) + if not repeated_item and field.get("num", 1) not in (0, 1): + return base * field["num"] + return base + if c_type == "char": + return field.get("num", 0) + base = { + "int8_t": 1, + "uint8_t": 1, + "int16_t": 2, + "uint16_t": 2, + "int32_t": 4, + "uint32_t": 4, + "int64_t": 8, + "uint64_t": 8, + "float": 4, + "float32_t": 4, + "float64_t": 8, + }[c_type] + if not repeated_item and field.get("num", 1) not in (0, 1): + return base * field["num"] + return base + # Generate rust conversion functions for _tdf_id, info in tdf_defs["definitions"].items(): info["rust_convs"] = [] fmt = [] - for f in info["fields"]: - if f["type"] in structs: - info["rust_convs"] += structs[f["type"]] - fmt += struct_fmts[f["type"]] - elif f["type"] in rust_type: - info["rust_convs"] += field_conv_func(f) - fmt += field_fmt(f) - else: - raise RuntimeError(f"Bad type '{f['type']}'") + info["csv_variable"] = None + variable_field = None + if info["fields"]: + last_field = info["fields"][-1] + if ( + last_field.get("num", None) == 0 + and last_field["type"] not in ("char", "uint8_t") + ): + variable_field = last_field + + fields = info["fields"][:-1] if variable_field is not None else info["fields"] + for f in fields: + csv_flatten_field(f, info["rust_convs"], fmt) + + if variable_field is not None: + variable_convs = [] + variable_fmt = [] + csv_flatten_field( + variable_field, + variable_convs, + variable_fmt, + variable_item=True, + ) + prefix_columns = len(info["rust_convs"]) + 1 + info["csv_variable"] = { + "base_size": sum(csv_field_byte_size(field) for field in fields), + "item_size": csv_field_byte_size(variable_field, repeated_item=True), + "fmt": ",".join(variable_fmt), + "empty_fmt": ",".join(fmt + ["{}"] * len(variable_fmt)), + "base_convs": list(info["rust_convs"]), + "convs": variable_convs, + "empty_suffix": "," * len(variable_fmt), + "continuation_prefix": "," * prefix_columns, + } + info["rust_convs"] += variable_convs + fmt += variable_fmt info["rust_head"] = ",".join([f'"{c[0]}"' for c in info["rust_convs"]]) info["rust_fmt"] = ",".join(fmt) diff --git a/scripts/tdf_decoder_csv.rs.jinja b/scripts/tdf_decoder_csv.rs.jinja index 13df6bf..e6bfe55 100644 --- a/scripts/tdf_decoder_csv.rs.jinja +++ b/scripts/tdf_decoder_csv.rs.jinja @@ -29,12 +29,64 @@ fn tdf_field_read_vla_to_str(cursor: &mut Cursor<&[u8]>, cursor_start: u64, size Ok(format!("{}", hex::encode(buf))) } +fn tdf_variable_item_count(size: u8, base_size: usize, item_size: usize) -> Result +{ + if (size as usize) < base_size { + return Result::Err(Error::new( + ErrorKind::InvalidData, + "Read underflow, corrupt data/metadata", + )); + } + + let bytes_remaining = size as usize - base_size; + if bytes_remaining % item_size != 0 { + return Result::Err(Error::new( + ErrorKind::InvalidData, + "Variable-length array does not align to element size", + )); + } + + Ok(bytes_remaining / item_size) +} + pub fn tdf_read_into_str(tdf_id: &u16, size: u8, cursor: &mut Cursor<&[u8]>) -> Result { let cursor_start = cursor.position(); let res = match tdf_id { {% for tdf_id, info in definitions.items() %} +{% if info['csv_variable'] %} + {{ tdf_id }} => { + let item_count = tdf_variable_item_count(size, {{ info['csv_variable']['base_size'] }}, {{ info['csv_variable']['item_size'] }})?; + if item_count == 0 { + Ok(format!( + "{{ info['csv_variable']['empty_fmt'] }}", +{% for conv in info['csv_variable']['base_convs'] %} + {{ conv[1] }}, +{% endfor %} +{% for _conv in info['csv_variable']['convs'] %} + "", +{% endfor %} + )) + } else { + let mut out = format!( + "{{ info['rust_fmt'] }}", +{% for conv in info['rust_convs'] %} + {{ conv[1] }}, +{% endfor %} + ); + for _ in 1..item_count { + out.push_str(&format!( + "\n{{ info['csv_variable']['continuation_prefix'] }}{{ info['csv_variable']['fmt'] }}", +{% for conv in info['csv_variable']['convs'] %} + {{ conv[1] }}, +{% endfor %} + )); + } + Ok(out) + } + }, +{% else %} {{ tdf_id }} => Ok(format!( "{{ info['rust_fmt'] }}", @@ -42,6 +94,7 @@ pub fn tdf_read_into_str(tdf_id: &u16, size: u8, cursor: &mut Cursor<&[u8]>) -> {{ conv[1] }}, {% endfor %} )), +{% endif %} {% endfor %} _ => { let mut buf = vec![0; size as usize]; @@ -68,3 +121,89 @@ pub fn tdf_read_into_str(tdf_id: &u16, size: u8, cursor: &mut Cursor<&[u8]>) -> } res } + +#[cfg(test)] +mod tests { + use super::*; + + fn tdf34_base_bytes() -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&2u16.to_le_bytes()); + bytes.extend_from_slice(&3u32.to_le_bytes()); + bytes.extend_from_slice(&4u16.to_le_bytes()); + bytes.extend_from_slice(&5u32.to_le_bytes()); + bytes.push(6); + bytes.push((-7i8) as u8); + bytes + } + + fn push_tdf34_neighbour(bytes: &mut Vec, earfcn: u32, pci: u16, time_diff: u16, rsrp: u8, rsrq: i8) { + bytes.extend_from_slice(&earfcn.to_le_bytes()); + bytes.extend_from_slice(&pci.to_le_bytes()); + bytes.extend_from_slice(&time_diff.to_le_bytes()); + bytes.push(rsrp); + bytes.push(rsrq as u8); + } + + #[test] + fn trailing_variable_array_zero_items_blanks_last_columns() { + let bytes = tdf34_base_bytes(); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&34, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!(row, "1,2,3,4,5,-6,-7,,,,,"); + } + + #[test] + fn trailing_variable_array_one_item_stays_on_first_row() { + let mut bytes = tdf34_base_bytes(); + push_tdf34_neighbour(&mut bytes, 100, 11, 2500, 8, -9); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&34, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!(row, "1,2,3,4,5,-6,-7,100,11,2.5,-8,-9"); + } + + #[test] + fn trailing_variable_array_extra_items_add_blank_prefix_rows() { + let mut bytes = tdf34_base_bytes(); + push_tdf34_neighbour(&mut bytes, 100, 11, 2500, 8, -9); + push_tdf34_neighbour(&mut bytes, 200, 12, 3000, 10, -11); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&34, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!( + row, + "1,2,3,4,5,-6,-7,100,11,2.5,-8,-9\n,,,,,,,,200,12,3,-10,-11" + ); + } + + #[test] + fn trailing_variable_array_items_use_single_field_formatting() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&0x12345678u32.to_le_bytes()); + bytes.extend_from_slice(&0x90abcdefu32.to_le_bytes()); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&52, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!(row, "0x12345678\n,0x90abcdef"); + } + + #[test] + fn trailing_uint8_variable_array_stays_as_hex_payload() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&0x12345678u32.to_le_bytes()); + bytes.extend_from_slice(&9u16.to_le_bytes()); + bytes.extend_from_slice(&[0xab, 0xcd, 0xef]); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&25, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!(row, "0x12345678,9,abcdef"); + } +} diff --git a/tdf/src/decoders.rs b/tdf/src/decoders.rs index 7c00032..32cb6b1 100644 --- a/tdf/src/decoders.rs +++ b/tdf/src/decoders.rs @@ -1,7 +1,6 @@ -use std::io::{Cursor, Read, Result, Error, ErrorKind}; +use std::io::{Cursor, Error, ErrorKind, Read, Result}; -pub fn tdf_name(tdf_id: &u16) -> String -{ +pub fn tdf_name(tdf_id: &u16) -> String { match tdf_id { 1 => String::from("ANNOUNCE"), 2 => String::from("BATTERY_STATE"), @@ -67,11 +66,14 @@ pub fn tdf_name(tdf_id: &u16) -> String } } -pub fn vla_bytes_remaining(cursor: &mut Cursor<&[u8]>, cursor_start: u64, size: u8) -> Result -{ +pub fn vla_bytes_remaining( + cursor: &mut Cursor<&[u8]>, + cursor_start: u64, + size: u8, +) -> Result { let cursor_current = cursor.position(); let cursor_read = cursor_current - cursor_start; - if cursor_read >= size as u64 { + if cursor_read > size as u64 { return Result::Err(Error::new( ErrorKind::InvalidData, "Insufficient data remaining", @@ -82,8 +84,12 @@ pub fn vla_bytes_remaining(cursor: &mut Cursor<&[u8]>, cursor_start: u64, size: Ok(bytes_remaining as usize) } -pub fn tdf_field_read_string(cursor: &mut Cursor<&[u8]>, cursor_start: u64, num: u8, size: u8) -> Result> -{ +pub fn tdf_field_read_string( + cursor: &mut Cursor<&[u8]>, + cursor_start: u64, + num: u8, + size: u8, +) -> Result> { let string_length = match num { 0 => vla_bytes_remaining(cursor, cursor_start, size)?, _ => num as usize, @@ -95,8 +101,11 @@ pub fn tdf_field_read_string(cursor: &mut Cursor<&[u8]>, cursor_start: u64, num: Ok(buf) } -pub fn tdf_field_read_vla(cursor: &mut Cursor<&[u8]>, cursor_start: u64, size: u8) -> Result> -{ +pub fn tdf_field_read_vla( + cursor: &mut Cursor<&[u8]>, + cursor_start: u64, + size: u8, +) -> Result> { let bytes_remaining = vla_bytes_remaining(cursor, cursor_start, size)?; let mut buf = vec![0u8; bytes_remaining]; diff --git a/tdf/src/decoders_csv.rs b/tdf/src/decoders_csv.rs index 35e7319..081db72 100644 --- a/tdf/src/decoders_csv.rs +++ b/tdf/src/decoders_csv.rs @@ -6,10 +6,10 @@ pub fn tdf_fields(tdf_id: &u16) -> Vec<&'static str> { match tdf_id { 1 => vec![ "application", - "major", - "minor", - "revision", - "build_num", + "version.major", + "version.minor", + "version.revision", + "version.build_num", "kv_crc", "blocks", "uptime", @@ -31,10 +31,10 @@ pub fn tdf_fields(tdf_id: &u16) -> Vec<&'static str> { ], 7 => vec![ "application", - "major", - "minor", - "revision", - "build_num", + "version.major", + "version.minor", + "version.revision", + "version.build_num", "board_crc", "kv_crc", "blocks", @@ -43,16 +43,22 @@ pub fn tdf_fields(tdf_id: &u16) -> Vec<&'static str> { "flags", ], 8 => vec!["temperature"], - 10 => vec!["x", "y", "z"], - 11 => vec!["x", "y", "z"], - 12 => vec!["x", "y", "z"], - 13 => vec!["x", "y", "z"], - 14 => vec!["x", "y", "z"], - 15 => vec!["x", "y", "z"], - 16 => vec!["x", "y", "z"], - 17 => vec!["x", "y", "z"], - 18 => vec!["x", "y", "z"], - 19 => vec!["latitude", "longitude", "height", "h_acc", "v_acc"], + 10 => vec!["sample.x", "sample.y", "sample.z"], + 11 => vec!["sample.x", "sample.y", "sample.z"], + 12 => vec!["sample.x", "sample.y", "sample.z"], + 13 => vec!["sample.x", "sample.y", "sample.z"], + 14 => vec!["sample.x", "sample.y", "sample.z"], + 15 => vec!["sample.x", "sample.y", "sample.z"], + 16 => vec!["sample.x", "sample.y", "sample.z"], + 17 => vec!["sample.x", "sample.y", "sample.z"], + 18 => vec!["sample.x", "sample.y", "sample.z"], + 19 => vec![ + "location.latitude", + "location.longitude", + "location.height", + "h_acc", + "v_acc", + ], 20 => vec![ "itow", "year", @@ -92,7 +98,8 @@ pub fn tdf_fields(tdf_id: &u16) -> Vec<&'static str> { "mag_acc", ], 21 => vec![ - "mcc", "mnc", "eci", "tac", "earfcn", "status", "tech", "rsrp", "rsrq", + "cell.mcc", "cell.mnc", "cell.eci", "cell.tac", "earfcn", "status", "tech", "rsrp", + "rsrq", ], 22 => vec![ "payload[0]", @@ -111,26 +118,26 @@ pub fn tdf_fields(tdf_id: &u16) -> Vec<&'static str> { 26 => vec!["error_id", "error_ctx"], 27 => vec!["enabled"], 28 => vec!["time_fix", "location_fix", "num_sv"], - 29 => vec!["type", "val", "connected"], - 30 => vec!["type", "val", "rssi"], - 31 => vec!["type", "val", "throughput"], + 29 => vec!["address.type", "address.val", "connected"], + 30 => vec!["address.type", "address.val", "rssi"], + 31 => vec!["address.type", "address.val", "throughput"], 32 => vec!["algorithm_id", "algorithm_version", "classes"], 33 => vec!["algorithm_id", "algorithm_version", "values"], 34 => vec![ - "mcc", - "mnc", - "eci", - "tac", + "cell.mcc", + "cell.mnc", + "cell.eci", + "cell.tac", "earfcn", "rsrp", "rsrq", - "earfcn", - "pci", - "time_diff", - "rsrp", - "rsrq", + "neighbours.earfcn", + "neighbours.pci", + "neighbours.time_diff", + "neighbours.rsrp", + "neighbours.rsrq", ], - 35 => vec!["val", "channel", "rsrp"], + 35 => vec!["bssid.val", "channel", "rsrp"], 36 => vec!["cosine"], 37 => vec![ "lat", @@ -169,15 +176,15 @@ pub fn tdf_fields(tdf_id: &u16) -> Vec<&'static str> { 46 => vec!["tdf_id", "frequency"], 47 => vec!["tdf_id", "period"], 48 => vec![ - "bssid", - "band", - "channel", - "iface_mode", - "link_mode", - "security", - "rssi", - "beacon_interval", - "twt_capable", + "network.bssid", + "network.band", + "network.channel", + "network.iface_mode", + "network.link_mode", + "network.security", + "network.rssi", + "network.beacon_interval", + "network.twt_capable", ], 49 => vec!["reason"], 50 => vec!["reason"], @@ -220,6 +227,25 @@ fn tdf_field_read_vla_to_str( Ok(format!("{}", hex::encode(buf))) } +fn tdf_variable_item_count(size: u8, base_size: usize, item_size: usize) -> Result { + if (size as usize) < base_size { + return Result::Err(Error::new( + ErrorKind::InvalidData, + "Read underflow, corrupt data/metadata", + )); + } + + let bytes_remaining = size as usize - base_size; + if bytes_remaining % item_size != 0 { + return Result::Err(Error::new( + ErrorKind::InvalidData, + "Variable-length array does not align to element size", + )); + } + + Ok(bytes_remaining / item_size) +} + pub fn tdf_read_into_str(tdf_id: &u16, size: u8, cursor: &mut Cursor<&[u8]>) -> Result { let cursor_start = cursor.position(); @@ -503,22 +529,53 @@ pub fn tdf_read_into_str(tdf_id: &u16, size: u8, cursor: &mut Cursor<&[u8]>) -> cursor.read_u16::()?, tdf_field_read_vla_to_str(cursor, cursor_start, size)?, )), - 34 => - Ok(format!( - "{},{},{},{},{},{},{},{},{},{},{},{}", - cursor.read_u16::()?, - cursor.read_u16::()?, - cursor.read_u32::()?, - cursor.read_u16::()?, - cursor.read_u32::()?, - cursor.read_u8()? as f64 / -1.0, - cursor.read_i8()?, - cursor.read_u32::()?, - cursor.read_u16::()?, - cursor.read_u16::()? as f64 / 1000.0, - cursor.read_u8()? as f64 / -1.0, - cursor.read_i8()?, - )), + 34 => { + let item_count = tdf_variable_item_count(size, 16, 10)?; + if item_count == 0 { + Ok(format!( + "{},{},{},{},{},{},{},{},{},{},{},{}", + cursor.read_u16::()?, + cursor.read_u16::()?, + cursor.read_u32::()?, + cursor.read_u16::()?, + cursor.read_u32::()?, + cursor.read_u8()? as f64 / -1.0, + cursor.read_i8()?, + "", + "", + "", + "", + "", + )) + } else { + let mut out = format!( + "{},{},{},{},{},{},{},{},{},{},{},{}", + cursor.read_u16::()?, + cursor.read_u16::()?, + cursor.read_u32::()?, + cursor.read_u16::()?, + cursor.read_u32::()?, + cursor.read_u8()? as f64 / -1.0, + cursor.read_i8()?, + cursor.read_u32::()?, + cursor.read_u16::()?, + cursor.read_u16::()? as f64 / 1000.0, + cursor.read_u8()? as f64 / -1.0, + cursor.read_i8()?, + ); + for _ in 1..item_count { + out.push_str(&format!( + "\n,,,,,,,,{},{},{},{},{}", + cursor.read_u32::()?, + cursor.read_u16::()?, + cursor.read_u16::()? as f64 / 1000.0, + cursor.read_u8()? as f64 / -1.0, + cursor.read_i8()?, + )); + } + Ok(out) + } + }, 35 => Ok(format!( "0x{:012x},{},{}", @@ -644,11 +701,27 @@ pub fn tdf_read_into_str(tdf_id: &u16, size: u8, cursor: &mut Cursor<&[u8]>) -> cursor.read_u8()?, cursor.read_u8()?, )), - 52 => - Ok(format!( - "{}", - tdf_field_read_vla_to_str(cursor, cursor_start, size)?, - )), + 52 => { + let item_count = tdf_variable_item_count(size, 0, 4)?; + if item_count == 0 { + Ok(format!( + "{}", + "", + )) + } else { + let mut out = format!( + "0x{:08x}", + cursor.read_u32::()?, + ); + for _ in 1..item_count { + out.push_str(&format!( + "\n,0x{:08x}", + cursor.read_u32::()?, + )); + } + Ok(out) + } + }, 53 => Ok(format!( "{}", @@ -722,3 +795,96 @@ pub fn tdf_read_into_str(tdf_id: &u16, size: u8, cursor: &mut Cursor<&[u8]>) -> } res } + +#[cfg(test)] +mod tests { + use super::*; + + fn tdf34_base_bytes() -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&1u16.to_le_bytes()); + bytes.extend_from_slice(&2u16.to_le_bytes()); + bytes.extend_from_slice(&3u32.to_le_bytes()); + bytes.extend_from_slice(&4u16.to_le_bytes()); + bytes.extend_from_slice(&5u32.to_le_bytes()); + bytes.push(6); + bytes.push((-7i8) as u8); + bytes + } + + fn push_tdf34_neighbour( + bytes: &mut Vec, + earfcn: u32, + pci: u16, + time_diff: u16, + rsrp: u8, + rsrq: i8, + ) { + bytes.extend_from_slice(&earfcn.to_le_bytes()); + bytes.extend_from_slice(&pci.to_le_bytes()); + bytes.extend_from_slice(&time_diff.to_le_bytes()); + bytes.push(rsrp); + bytes.push(rsrq as u8); + } + + #[test] + fn trailing_variable_array_zero_items_blanks_last_columns() { + let bytes = tdf34_base_bytes(); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&34, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!(row, "1,2,3,4,5,-6,-7,,,,,"); + } + + #[test] + fn trailing_variable_array_one_item_stays_on_first_row() { + let mut bytes = tdf34_base_bytes(); + push_tdf34_neighbour(&mut bytes, 100, 11, 2500, 8, -9); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&34, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!(row, "1,2,3,4,5,-6,-7,100,11,2.5,-8,-9"); + } + + #[test] + fn trailing_variable_array_extra_items_add_blank_prefix_rows() { + let mut bytes = tdf34_base_bytes(); + push_tdf34_neighbour(&mut bytes, 100, 11, 2500, 8, -9); + push_tdf34_neighbour(&mut bytes, 200, 12, 3000, 10, -11); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&34, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!( + row, + "1,2,3,4,5,-6,-7,100,11,2.5,-8,-9\n,,,,,,,,200,12,3,-10,-11" + ); + } + + #[test] + fn trailing_variable_array_items_use_single_field_formatting() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&0x12345678u32.to_le_bytes()); + bytes.extend_from_slice(&0x90abcdefu32.to_le_bytes()); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&52, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!(row, "0x12345678\n,0x90abcdef"); + } + + #[test] + fn trailing_uint8_variable_array_stays_as_hex_payload() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&0x12345678u32.to_le_bytes()); + bytes.extend_from_slice(&9u16.to_le_bytes()); + bytes.extend_from_slice(&[0xab, 0xcd, 0xef]); + let mut cursor = Cursor::new(bytes.as_slice()); + + let row = tdf_read_into_str(&25, bytes.len() as u8, &mut cursor).unwrap(); + + assert_eq!(row, "0x12345678,9,abcdef"); + } +}