use crate::blocks::common::{read_link, BlockHeader, ParseBlock, BLOCK_HEADER_SIZE};
use crate::blocks::formula::Expr;
use crate::error::{Mf4Error, Result};
use byteorder::{LittleEndian, ReadBytesExt};
use std::borrow::Cow;
use std::io::Cursor;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConversionType {
Identity,
Linear,
Rational,
Algebraic,
TabInterpolation,
TabLookup,
TabRangeLookup,
TabValueToText,
TabRangeToText,
TabTextToValue,
TabTextToText,
BitfieldToText,
Unknown(u8),
}
impl ConversionType {
fn from_u8(value: u8) -> Self {
match value {
0 => ConversionType::Identity,
1 => ConversionType::Linear,
2 => ConversionType::Rational,
3 => ConversionType::Algebraic,
4 => ConversionType::TabInterpolation,
5 => ConversionType::TabLookup,
6 => ConversionType::TabRangeLookup,
7 => ConversionType::TabValueToText,
8 => ConversionType::TabRangeToText,
9 => ConversionType::TabTextToValue,
10 => ConversionType::TabTextToText,
11 => ConversionType::BitfieldToText,
v => ConversionType::Unknown(v),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CcFlags {
pub precision_valid: bool,
pub range_valid: bool,
pub status_string: bool,
}
impl CcFlags {
fn from_u16(value: u16) -> Self {
CcFlags {
precision_valid: (value & 0x01) != 0,
range_valid: (value & 0x02) != 0,
status_string: (value & 0x04) != 0,
}
}
}
#[derive(Debug, Clone)]
pub struct CcBlock {
pub header: BlockHeader,
pub tx_name: u64,
pub md_unit: u64,
pub md_comment: u64,
pub cc_inverse: u64,
pub references: Vec<u64>,
pub conversion_type: ConversionType,
pub precision: u8,
pub flags: CcFlags,
pub ref_count: u16,
pub val_count: u16,
pub phy_range_min: f64,
pub phy_range_max: f64,
pub values: Vec<f64>,
}
impl CcBlock {
pub const MIN_SIZE: u64 = BLOCK_HEADER_SIZE as u64 + 4 * 8 + 24;
}
impl ParseBlock for CcBlock {
fn parse(data: &[u8], offset: u64) -> Result<Self> {
let header = BlockHeader::parse(data, offset)?;
header.validate_type(b"##CC", offset)?;
if header.length < Self::MIN_SIZE {
return Err(Mf4Error::invalid_block_size(
"CC",
header.length,
Self::MIN_SIZE,
));
}
let links_start = BLOCK_HEADER_SIZE;
let tx_name = read_link(data, links_start)?;
let md_unit = read_link(data, links_start + 8)?;
let md_comment = read_link(data, links_start + 16)?;
let cc_inverse = read_link(data, links_start + 24)?;
let extra_links = header.link_count.saturating_sub(4) as usize;
let mut references = Vec::with_capacity(extra_links);
for i in 0..extra_links {
references.push(read_link(data, links_start + 32 + i * 8)?);
}
let data_start = header.data_offset();
if data.len() < data_start + 24 {
return Err(Mf4Error::truncated(offset, data_start + 24, data.len()));
}
let data_section = data
.get(data_start..)
.ok_or_else(|| Mf4Error::truncated(offset, data_start, data.len()))?;
let mut cursor = Cursor::new(data_section);
let conversion_type_raw = cursor.read_u8()?;
let conversion_type = ConversionType::from_u8(conversion_type_raw);
let precision = cursor.read_u8()?;
let flags_raw = cursor.read_u16::<LittleEndian>()?;
let flags = CcFlags::from_u16(flags_raw);
let ref_count = cursor.read_u16::<LittleEndian>()?;
let val_count = cursor.read_u16::<LittleEndian>()?;
let phy_range_min = cursor.read_f64::<LittleEndian>()?;
let phy_range_max = cursor.read_f64::<LittleEndian>()?;
let mut values = Vec::with_capacity(val_count as usize);
for _ in 0..val_count {
values.push(cursor.read_f64::<LittleEndian>()?);
}
Ok(CcBlock {
header,
tx_name,
md_unit,
md_comment,
cc_inverse,
references,
conversion_type,
precision,
flags,
ref_count,
val_count,
phy_range_min,
phy_range_max,
values,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConversionInput {
Numeric,
Text,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ConversionOutput {
Numeric,
Text,
Unsupported,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum TableEntry {
Text(String),
Nested(Box<Conversion>),
}
impl TableEntry {
pub fn text(&self) -> Option<&str> {
match self {
TableEntry::Text(t) => Some(t),
TableEntry::Nested(_) => None,
}
}
pub fn is_nested(&self) -> bool {
matches!(self, TableEntry::Nested(_))
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum BitfieldEntry {
Flag(String),
Nested {
name: String,
conversion: Box<Conversion>,
},
Unresolved,
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub enum Conversion {
#[default]
None,
Linear {
offset: f64,
factor: f64,
},
Rational {
coefficients: [f64; 6],
},
Algebraic {
formula: String,
expr: Expr,
},
TableInterpolated {
keys: Vec<f64>,
values: Vec<f64>,
},
TableLookup {
keys: Vec<f64>,
values: Vec<f64>,
},
RangeTable {
lower: Vec<f64>,
upper: Vec<f64>,
values: Vec<f64>,
default: Option<f64>,
},
ValueToText {
keys: Vec<f64>,
entries: Vec<TableEntry>,
default: Option<TableEntry>,
},
RangeToText {
lower: Vec<f64>,
upper: Vec<f64>,
entries: Vec<TableEntry>,
default: Option<TableEntry>,
},
TextToValue {
keys: Vec<String>,
values: Vec<f64>,
default: Option<f64>,
},
TextToText {
keys: Vec<String>,
texts: Vec<String>,
default: Option<String>,
},
Bitfield {
masks: Vec<u64>,
entries: Vec<BitfieldEntry>,
},
Unsupported {
kind: ConversionType,
reason: String,
},
}
impl Conversion {
pub fn is_identity(&self) -> bool {
match self {
Conversion::None => true,
Conversion::Linear { offset, factor } => *offset == 0.0 && *factor == 1.0,
_ => false,
}
}
pub fn input(&self) -> ConversionInput {
match self {
Conversion::TextToValue { .. } | Conversion::TextToText { .. } => ConversionInput::Text,
_ => ConversionInput::Numeric,
}
}
pub fn output(&self) -> ConversionOutput {
match self {
Conversion::ValueToText {
entries, default, ..
}
| Conversion::RangeToText {
entries, default, ..
} => {
let all_nested = entries.iter().all(TableEntry::is_nested)
&& default.as_ref().is_none_or(TableEntry::is_nested);
if all_nested {
ConversionOutput::Numeric
} else {
ConversionOutput::Text
}
}
Conversion::TextToText { .. } | Conversion::Bitfield { .. } => ConversionOutput::Text,
Conversion::Unsupported { .. } => ConversionOutput::Unsupported,
_ => ConversionOutput::Numeric,
}
}
pub fn convert(&self, raw: f64, is_float: bool) -> f64 {
match self {
Conversion::None => raw,
Conversion::Linear { offset, factor } => factor * raw + offset,
Conversion::Rational { coefficients } => {
let [p0, p1, p2, p3, p4, p5] = *coefficients;
let num = p0 * raw * raw + p1 * raw + p2;
let den = p3 * raw * raw + p4 * raw + p5;
num / den
}
Conversion::Algebraic { expr, .. } => expr.eval(raw),
Conversion::TableInterpolated { keys, values } => interpolate(keys, values, raw),
Conversion::TableLookup { keys, values } => nearest(keys, values, raw),
Conversion::RangeTable {
lower,
upper,
values,
default,
} => {
for i in (0..values.len()).rev() {
if in_range(raw, lower[i], upper[i], is_float) {
return values[i];
}
}
default.unwrap_or(f64::NAN)
}
Conversion::ValueToText {
keys,
entries,
default,
} => {
let hit = keys
.iter()
.position(|k| *k == raw)
.and_then(|i| entries.get(i))
.or(default.as_ref());
match hit {
Some(TableEntry::Nested(c)) => c.convert(raw, is_float),
_ => f64::NAN,
}
}
Conversion::RangeToText {
lower,
upper,
entries,
default,
} => {
let hit = (0..entries.len())
.rev()
.find(|&i| in_range(raw, lower[i], upper[i], is_float))
.and_then(|i| entries.get(i))
.or(default.as_ref());
match hit {
Some(TableEntry::Nested(c)) => c.convert(raw, is_float),
_ => f64::NAN,
}
}
Conversion::TextToValue { .. }
| Conversion::TextToText { .. }
| Conversion::Bitfield { .. }
| Conversion::Unsupported { .. } => f64::NAN,
}
}
pub fn convert_text(&self, raw: f64, is_float: bool) -> Option<Cow<'_, str>> {
let hit = match self {
Conversion::ValueToText {
keys,
entries,
default,
} => keys
.iter()
.position(|k| *k == raw)
.and_then(|i| entries.get(i))
.or(default.as_ref()),
Conversion::RangeToText {
lower,
upper,
entries,
default,
} => (0..entries.len())
.rev()
.find(|&i| in_range(raw, lower[i], upper[i], is_float))
.and_then(|i| entries.get(i))
.or(default.as_ref()),
_ => return None,
};
match hit? {
TableEntry::Text(t) => Some(Cow::Borrowed(t.as_str())),
TableEntry::Nested(c) => Some(Cow::Owned(format_number(c.convert(raw, is_float)))),
}
}
pub fn render_bitfield(&self, raw: f64) -> Option<String> {
let Conversion::Bitfield { masks, entries } = self else {
return None;
};
if !raw.is_finite() || raw.fract() != 0.0 || raw < 0.0 {
return Some(String::new());
}
let value = raw as u64;
let mut parts: Vec<String> = Vec::new();
for (mask, entry) in masks.iter().zip(entries) {
let masked = value & mask;
match entry {
BitfieldEntry::Flag(label) => {
if masked != 0 && !label.is_empty() {
parts.push(label.clone());
}
}
BitfieldEntry::Nested { name, conversion } => {
let rendered = match conversion.output() {
ConversionOutput::Text => conversion
.convert_text(masked as f64, false)
.unwrap_or_default()
.to_string(),
ConversionOutput::Numeric => {
let converted = conversion.convert(masked as f64, false);
if converted.is_nan() {
String::new()
} else {
converted.to_string()
}
}
ConversionOutput::Unsupported => String::new(),
};
match (name.is_empty(), rendered.is_empty()) {
(_, true) => {}
(true, false) => parts.push(rendered),
(false, false) => parts.push(format!("{name} = {rendered}")),
}
}
BitfieldEntry::Unresolved => {}
}
}
Some(parts.join(" | "))
}
pub fn value_for_text(&self, text: &str) -> Option<f64> {
let Conversion::TextToValue {
keys,
values,
default,
} = self
else {
return None;
};
keys.iter()
.position(|k| k == text)
.and_then(|i| values.get(i).copied())
.or(*default)
}
pub fn text_for_text(&self, text: &str) -> Option<&str> {
let Conversion::TextToText {
keys,
texts,
default,
} = self
else {
return None;
};
keys.iter()
.position(|k| k == text)
.and_then(|i| texts.get(i).map(|s| s.as_str()))
.or(default.as_deref())
}
}
fn format_number(v: f64) -> String {
if v.is_finite() && v.fract() == 0.0 && v.abs() < 1e15 {
format!("{v:.0}")
} else {
format!("{v}")
}
}
fn in_range(raw: f64, lower: f64, upper: f64, is_float: bool) -> bool {
if is_float {
raw >= lower && raw < upper
} else {
raw >= lower && raw <= upper
}
}
fn interpolate(keys: &[f64], values: &[f64], raw: f64) -> f64 {
let n = keys.len().min(values.len());
if n == 0 {
return raw;
}
if raw <= keys[0] {
return values[0];
}
if raw >= keys[n - 1] {
return values[n - 1];
}
for i in 0..n - 1 {
if raw >= keys[i] && raw <= keys[i + 1] {
let span = keys[i + 1] - keys[i];
if span == 0.0 {
return values[i];
}
let t = (raw - keys[i]) / span;
return values[i] + t * (values[i + 1] - values[i]);
}
}
raw
}
fn nearest(keys: &[f64], values: &[f64], raw: f64) -> f64 {
let n = keys.len().min(values.len());
if n == 0 {
return raw;
}
let mut best = 0usize;
let mut best_diff = f64::MAX;
for (i, k) in keys.iter().take(n).enumerate() {
let diff = (raw - k).abs();
if diff < best_diff {
best_diff = diff;
best = i;
}
}
values[best]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maps_every_conversion_type_code() {
assert_eq!(ConversionType::from_u8(0), ConversionType::Identity);
assert_eq!(ConversionType::from_u8(1), ConversionType::Linear);
assert_eq!(ConversionType::from_u8(2), ConversionType::Rational);
assert_eq!(ConversionType::from_u8(3), ConversionType::Algebraic);
assert_eq!(ConversionType::from_u8(4), ConversionType::TabInterpolation);
assert_eq!(ConversionType::from_u8(5), ConversionType::TabLookup);
assert_eq!(ConversionType::from_u8(6), ConversionType::TabRangeLookup);
assert_eq!(ConversionType::from_u8(7), ConversionType::TabValueToText);
assert_eq!(ConversionType::from_u8(8), ConversionType::TabRangeToText);
assert_eq!(ConversionType::from_u8(9), ConversionType::TabTextToValue);
assert_eq!(ConversionType::from_u8(10), ConversionType::TabTextToText);
assert_eq!(ConversionType::from_u8(11), ConversionType::BitfieldToText);
assert!(matches!(
ConversionType::from_u8(99),
ConversionType::Unknown(99)
));
}
#[test]
fn applies_a_linear_conversion() {
let c = Conversion::Linear {
offset: 2.0,
factor: 3.0,
};
assert_eq!(c.convert(4.0, false), 14.0, "y = factor*x + offset");
}
#[test]
fn applies_the_rational_conversion_in_spec_order() {
let c = Conversion::Rational {
coefficients: [1.0, 2.0, 3.0, 0.0, 0.0, 2.0],
};
assert_eq!(c.convert(2.0, false), 5.5);
}
#[test]
fn rational_with_a_zero_denominator_follows_ieee() {
let c = Conversion::Rational {
coefficients: [0.0, 0.0, 1.0, 0.0, 0.0, 0.0],
};
assert!(c.convert(1.0, false).is_infinite());
}
#[test]
fn interpolates_between_table_keys() {
let c = Conversion::TableInterpolated {
keys: vec![0.0, 10.0],
values: vec![100.0, 200.0],
};
assert_eq!(c.convert(5.0, false), 150.0);
assert_eq!(c.convert(0.0, false), 100.0);
assert_eq!(c.convert(10.0, false), 200.0);
assert_eq!(c.convert(-5.0, false), 100.0, "clamps below the first key");
assert_eq!(c.convert(50.0, false), 200.0, "clamps above the last key");
}
#[test]
fn table_lookup_does_not_interpolate() {
let c = Conversion::TableLookup {
keys: vec![0.0, 10.0],
values: vec![100.0, 200.0],
};
assert_eq!(c.convert(1.0, false), 100.0);
assert_eq!(c.convert(9.0, false), 200.0);
}
#[test]
fn range_table_selects_the_matching_range() {
let c = Conversion::RangeTable {
lower: vec![0.0, 10.0],
upper: vec![9.0, 19.0],
values: vec![1.0, 2.0],
default: Some(-1.0),
};
assert_eq!(c.convert(5.0, false), 1.0);
assert_eq!(c.convert(15.0, false), 2.0);
assert_eq!(c.convert(100.0, false), -1.0, "falls back to the default");
}
#[test]
fn range_table_without_a_default_yields_nan() {
let c = Conversion::RangeTable {
lower: vec![0.0],
upper: vec![1.0],
values: vec![7.0],
default: None,
};
assert!(c.convert(50.0, false).is_nan());
}
fn labels(items: &[&str]) -> Vec<TableEntry> {
items
.iter()
.map(|t| TableEntry::Text((*t).into()))
.collect()
}
#[test]
fn value_to_text_matches_exact_keys() {
let c = Conversion::ValueToText {
keys: vec![0.0, 1.0],
entries: labels(&["off", "on"]),
default: Some(TableEntry::Text("unknown".into())),
};
assert_eq!(c.convert_text(0.0, false).as_deref(), Some("off"));
assert_eq!(c.convert_text(1.0, false).as_deref(), Some("on"));
assert_eq!(c.convert_text(2.0, false).as_deref(), Some("unknown"));
assert_eq!(c.output(), ConversionOutput::Text);
}
#[test]
fn range_bounds_are_closed_so_a_shared_boundary_lands_in_the_later_range() {
let c = Conversion::RangeToText {
lower: vec![1.0, 3.0, 5.0],
upper: vec![3.0, 5.0, 7.0],
entries: labels(&["very low", "low", "medium"]),
default: Some(TableEntry::Text("Out of range".into())),
};
assert_eq!(
c.convert_text(1.0, false).as_deref(),
Some("very low"),
"lower is inclusive"
);
assert_eq!(c.convert_text(2.9, false).as_deref(), Some("very low"));
assert_eq!(
c.convert_text(3.0, false).as_deref(),
Some("low"),
"a shared boundary belongs to the later range"
);
assert_eq!(c.convert_text(5.0, false).as_deref(), Some("medium"));
assert_eq!(c.convert_text(6.9, false).as_deref(), Some("medium"));
assert_eq!(
c.convert_text(7.0, false).as_deref(),
Some("medium"),
"the last range's upper bound is its own"
);
assert_eq!(
c.convert_text(7.1, false).as_deref(),
Some("Out of range"),
"past the last"
);
assert_eq!(
c.convert_text(0.0, false).as_deref(),
Some("Out of range"),
"before the first"
);
}
#[test]
fn a_range_table_shares_the_closed_bounds_rule() {
let c = Conversion::RangeTable {
lower: vec![1.0, 3.0],
upper: vec![3.0, 5.0],
values: vec![10.0, 20.0],
default: Some(-1.0),
};
assert_eq!(c.convert(1.0, false), 10.0);
assert_eq!(c.convert(2.9, false), 10.0);
assert_eq!(
c.convert(3.0, false),
20.0,
"the boundary belongs to the next range"
);
assert_eq!(
c.convert(5.0, false),
20.0,
"the last range owns its upper bound"
);
assert_eq!(c.convert(5.1, false), -1.0, "past the last range");
}
#[test]
fn float_range_table_upper_bound_is_exclusive() {
let c = Conversion::RangeToText {
lower: vec![0.0, 0.5, 1.0],
upper: vec![0.5, 1.0, 2.0],
entries: labels(&["lower range", "mid-range", "higher range"]),
default: Some(TableEntry::Text("default".into())),
};
assert_eq!(c.convert_text(0.0, true).as_deref(), Some("lower range"));
assert_eq!(c.convert_text(0.5, true).as_deref(), Some("mid-range"));
assert_eq!(c.convert_text(1.0, true).as_deref(), Some("higher range"));
assert_eq!(c.convert_text(1.5, true).as_deref(), Some("higher range"));
assert_eq!(
c.convert_text(2.0, true).as_deref(),
Some("default"),
"upper bound 2.0 is exclusive for float channels"
);
}
#[test]
fn integer_single_point_ranges_match_on_closed_bounds() {
let c = Conversion::RangeTable {
lower: vec![100.0, 101.0],
upper: vec![100.0, 101.0],
values: vec![1.0, 2.0],
default: Some(-1.0),
};
assert_eq!(c.convert(100.0, false), 1.0);
assert_eq!(c.convert(101.0, false), 2.0);
assert_eq!(c.convert(99.0, false), -1.0);
}
#[test]
fn text_conversions_have_no_numeric_result() {
let c = Conversion::ValueToText {
keys: vec![0.0],
entries: labels(&["off"]),
default: None,
};
assert!(c.convert(0.0, false).is_nan());
}
#[test]
fn a_table_of_nested_conversions_is_piecewise_and_numeric() {
let c = Conversion::RangeToText {
lower: vec![0.5, 2.2],
upper: vec![2.2, 3.2],
entries: vec![
TableEntry::Nested(Box::new(Conversion::Linear {
offset: 5.67,
factor: 2.34,
})),
TableEntry::Nested(Box::new(Conversion::None)),
],
default: Some(TableEntry::Nested(Box::new(Conversion::Linear {
offset: -1.0,
factor: 0.0,
}))),
};
assert_eq!(c.output(), ConversionOutput::Numeric);
assert_eq!(c.convert(1.0, false), 5.67 + 2.34);
assert_eq!(c.convert(2.5, false), 2.5, "the identity branch");
assert_eq!(
c.convert(0.0, false),
-1.0,
"outside every range, so the default"
);
}
#[test]
fn a_table_mixing_labels_and_conversions_still_reads_as_text() {
let c = Conversion::RangeToText {
lower: vec![9.9999],
upper: vec![10.1001],
entries: labels(&["Illegal value"]),
default: Some(TableEntry::Nested(Box::new(Conversion::Linear {
offset: 0.0,
factor: 2.0,
}))),
};
assert_eq!(c.output(), ConversionOutput::Text);
assert_eq!(
c.convert_text(10.0, false).as_deref(),
Some("Illegal value")
);
assert_eq!(c.convert_text(3.0, false).as_deref(), Some("6"));
}
#[test]
fn numeric_conversions_have_no_text_result() {
let c = Conversion::Linear {
offset: 0.0,
factor: 1.0,
};
assert_eq!(c.convert_text(1.0, false), None);
}
#[test]
fn evaluates_an_algebraic_conversion() {
let expr = Expr::parse("2*X + 1").unwrap();
let c = Conversion::Algebraic {
formula: "2*X + 1".into(),
expr,
};
assert_eq!(c.convert(3.0, false), 7.0);
assert_eq!(c.output(), ConversionOutput::Numeric);
}
#[test]
fn unsupported_conversions_report_themselves_as_such() {
let c = Conversion::Unsupported {
kind: ConversionType::BitfieldToText,
reason: "nested conversions".into(),
};
assert_eq!(c.output(), ConversionOutput::Unsupported);
assert!(!c.is_identity(), "must never be mistaken for identity");
}
#[test]
fn identity_recognises_both_spellings() {
assert!(Conversion::None.is_identity());
assert!(Conversion::Linear {
offset: 0.0,
factor: 1.0
}
.is_identity());
assert!(!Conversion::Linear {
offset: 1.0,
factor: 1.0
}
.is_identity());
assert!(!Conversion::Linear {
offset: 0.0,
factor: 2.0
}
.is_identity());
}
#[test]
fn a_text_to_value_table_looks_up_by_string() {
let c = Conversion::TextToValue {
keys: vec!["off".into(), "on".into()],
values: vec![0.0, 1.0],
default: Some(-1.0),
};
assert_eq!(c.value_for_text("off"), Some(0.0));
assert_eq!(c.value_for_text("on"), Some(1.0));
assert_eq!(c.value_for_text("elsewhere"), Some(-1.0));
assert_eq!(c.input(), ConversionInput::Text);
assert_eq!(c.output(), ConversionOutput::Numeric);
assert!(c.convert(0.0, false).is_nan());
}
#[test]
fn a_text_to_value_table_without_a_default_has_no_result_for_an_unknown_key() {
let c = Conversion::TextToValue {
keys: vec!["on".into()],
values: vec![1.0],
default: None,
};
assert_eq!(c.value_for_text("on"), Some(1.0));
assert_eq!(c.value_for_text("off"), None);
}
#[test]
fn a_text_to_text_table_translates_and_falls_back_to_its_default() {
let c = Conversion::TextToText {
keys: vec!["ok".into(), "err".into()],
texts: vec!["Healthy".into(), "Faulted".into()],
default: Some("Unrecognised".into()),
};
assert_eq!(c.text_for_text("ok"), Some("Healthy"));
assert_eq!(c.text_for_text("err"), Some("Faulted"));
assert_eq!(c.text_for_text("???"), Some("Unrecognised"));
assert_eq!(c.input(), ConversionInput::Text);
assert_eq!(c.output(), ConversionOutput::Text);
}
#[test]
fn the_text_lookups_ignore_conversions_that_are_not_keyed_by_text() {
let numeric = Conversion::Linear {
offset: 1.0,
factor: 2.0,
};
assert_eq!(numeric.value_for_text("1"), None);
assert_eq!(numeric.text_for_text("1"), None);
assert_eq!(numeric.input(), ConversionInput::Numeric);
}
#[test]
fn integer_range_tables_keep_inclusive_single_point_ranges() {
let c = Conversion::RangeTable {
lower: vec![100.0, 101.0, 102.0],
upper: vec![100.0, 101.0, 102.0],
values: vec![10.0, 11.0, 12.0],
default: Some(-1.0),
};
assert_eq!(
c.convert(100.0, false),
10.0,
"single-point lower bound is inclusive"
);
assert_eq!(c.convert(101.0, false), 11.0);
assert_eq!(c.convert(102.0, false), 12.0);
assert_eq!(c.convert(99.0, false), -1.0, "below the first range");
assert_eq!(c.convert(103.0, false), -1.0, "above the last range");
}
#[test]
fn integer_range_tables_match_an_upper_bound_followed_by_a_gap() {
let c = Conversion::RangeTable {
lower: vec![0.0, 10.0],
upper: vec![5.0, 15.0],
values: vec![1.0, 2.0],
default: Some(-1.0),
};
assert_eq!(c.convert(5.0, false), 1.0, "upper bound is inclusive");
assert_eq!(c.convert(6.0, false), -1.0, "gap after the first range");
assert_eq!(
c.convert(10.0, false),
2.0,
"next range's lower bound is inclusive"
);
}
#[test]
fn float_range_tables_use_an_exclusive_upper_bound() {
let c = Conversion::RangeTable {
lower: vec![1.0, 3.0],
upper: vec![3.0, 5.0],
values: vec![10.0, 20.0],
default: Some(-1.0),
};
assert_eq!(c.convert(1.0, true), 10.0, "lower is inclusive");
assert_eq!(c.convert(2.9, true), 10.0);
assert_eq!(
c.convert(3.0, true),
20.0,
"3.0 is the lower bound of the second range, which is inclusive"
);
assert_eq!(c.convert(4.9, true), 20.0);
assert_eq!(
c.convert(5.0, true),
-1.0,
"5.0 equals the final range's exclusive upper"
);
assert_eq!(c.convert(5.1, true), -1.0, "past the last range");
}
#[test]
fn float_range_to_text_uses_an_exclusive_upper_bound() {
let c = Conversion::RangeToText {
lower: vec![1.0],
upper: vec![3.0],
entries: labels(&["a"]),
default: Some(TableEntry::Text("default".into())),
};
assert_eq!(c.convert_text(1.0, true).as_deref(), Some("a"));
assert_eq!(c.convert_text(2.9, true).as_deref(), Some("a"));
assert_eq!(
c.convert_text(3.0, true).as_deref(),
Some("default"),
"3.0 equals the final range's exclusive upper"
);
}
fn gearbox_bitfield() -> Conversion {
Conversion::Bitfield {
masks: vec![0x000F, 0x0010],
entries: vec![
BitfieldEntry::Nested {
name: "gear".into(),
conversion: Box::new(Conversion::ValueToText {
keys: vec![1.0, 2.0],
entries: labels(&["first", "second"]),
default: Some(TableEntry::Text("unknown".into())),
}),
},
BitfieldEntry::Flag("clutch".into()),
],
}
}
#[test]
fn a_bitfield_renders_each_masked_field_with_its_name() {
let c = gearbox_bitfield();
assert_eq!(
c.render_bitfield(0x11 as f64).as_deref(),
Some("gear = first | clutch")
);
assert_eq!(c.render_bitfield(2.0).as_deref(), Some("gear = second"));
assert_eq!(c.output(), ConversionOutput::Text);
}
#[test]
fn a_flag_is_rendered_only_when_its_mask_selects_a_set_bit() {
let c = gearbox_bitfield();
assert_eq!(c.render_bitfield(1.0).as_deref(), Some("gear = first"));
assert_eq!(
c.render_bitfield(0x10 as f64).as_deref(),
Some("gear = unknown | clutch"),
"gear 0 matches no key, so the nested default applies"
);
}
#[test]
fn a_bitfield_cannot_mask_a_value_that_is_not_a_whole_number() {
let c = gearbox_bitfield();
assert_eq!(c.render_bitfield(1.5).as_deref(), Some(""));
assert_eq!(c.render_bitfield(f64::NAN).as_deref(), Some(""));
assert_eq!(c.render_bitfield(-1.0).as_deref(), Some(""));
}
#[test]
fn an_unresolved_bitfield_entry_contributes_nothing() {
let c = Conversion::Bitfield {
masks: vec![0xFF, 0xFF00],
entries: vec![
BitfieldEntry::Unresolved,
BitfieldEntry::Flag("high".into()),
],
};
assert_eq!(c.render_bitfield(0xFFFF as f64).as_deref(), Some("high"));
}
#[test]
fn render_bitfield_ignores_conversions_that_are_not_bitfields() {
assert_eq!(Conversion::None.render_bitfield(1.0), None);
}
}