From b8def8f0eb6cb0170581c5c16134fe287b2fae81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Dec 2025 18:24:56 +0000 Subject: [PATCH 1/3] Add comprehensive test suite and enhanced CI/CD workflow - Add unit tests for units.rs covering all conversion functions (temperature, pressure, speed, distance, fuel economy, volume, flow, acceleration) - Add tests for Speeduino/rusEFI binary MLG parser - Add tests for core types (Log, Value, Channel, Meta, EcuType) - Create integration tests that verify parsing of all supported ECU formats using example log files (Haltech, ECUMaster, Speeduino, rusEFI) - Enhance GitHub Actions CI/CD workflow with: - Cargo caching for faster builds - Separate jobs for format, lint, test, build, and docs - Cross-platform testing (Ubuntu, Windows, macOS) - Release build verification - Documentation build check Test coverage now includes 90 unit tests and 12 integration tests. --- .github/workflows/ci.yml | 229 ++++++++++++++++++- src/parsers/speeduino.rs | 267 +++++++++++++++++++++++ src/parsers/types.rs | 323 +++++++++++++++++++++++++++ src/units.rs | 361 ++++++++++++++++++++++++++++++ tests/integration_tests.rs | 435 +++++++++++++++++++++++++++++++++++++ 5 files changed, 1603 insertions(+), 12 deletions(-) create mode 100644 tests/integration_tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 397edcd9..82749356 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,94 @@ on: env: CARGO_TERM_COLOR: always RUST_BACKTRACE: 1 + # Faster compilation with incremental disabled for CI + CARGO_INCREMENTAL: 0 jobs: - check: - name: Check ${{ matrix.os }} + # ============================================ + # Format Check - Fast, runs first + # ============================================ + format: + name: Format Check + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check formatting + run: cargo fmt --all -- --check + + # ============================================ + # Lint - Clippy analysis + # ============================================ + lint: + name: Lint (Clippy) + runs-on: ubuntu-latest + needs: format + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxkbcommon-dev \ + libssl-dev \ + libgtk-3-dev \ + libglib2.0-dev \ + libatk1.0-dev \ + libcairo2-dev \ + libpango1.0-dev \ + libgdk-pixbuf2.0-dev \ + libwayland-dev \ + libxcb1-dev \ + libxcb-randr0-dev \ + libxcb-xkb-dev \ + libx11-xcb-dev + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache Cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-lint-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build-lint- + + - name: Run Clippy + run: cargo clippy --all-targets --all-features -- -D warnings + continue-on-error: true + + # ============================================ + # Test - Run on all platforms + # ============================================ + test: + name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} + needs: format strategy: fail-fast: false matrix: @@ -48,27 +131,149 @@ jobs: libxcb-xkb-dev \ libx11-xcb-dev + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache Cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-test-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build-test- + - name: Run cargo check run: cargo check --all-targets - - name: Run cargo test - run: cargo test + - name: Run tests + run: cargo test --all-features -- --nocapture - - name: Run cargo clippy - run: cargo clippy -- -D warnings - continue-on-error: true + # ============================================ + # Build - Verify release builds work + # ============================================ + build: + name: Build (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: [lint, test] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] - format: - name: Format check + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install Linux dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxkbcommon-dev \ + libssl-dev \ + libgtk-3-dev \ + libglib2.0-dev \ + libatk1.0-dev \ + libcairo2-dev \ + libpango1.0-dev \ + libgdk-pixbuf2.0-dev \ + libwayland-dev \ + libxcb1-dev \ + libxcb-randr0-dev \ + libxcb-xkb-dev \ + libx11-xcb-dev + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Cache Cargo build + uses: actions/cache@v4 + with: + path: target + key: ${{ runner.os }}-cargo-build-release-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-build-release- + + - name: Build release binary + run: cargo build --release + + - name: Verify binary exists (Unix) + if: matrix.os != 'windows-latest' + run: | + ls -la target/release/ultralog + file target/release/ultralog + + - name: Verify binary exists (Windows) + if: matrix.os == 'windows-latest' + run: | + dir target\release\ultralog.exe + + # ============================================ + # Documentation - Verify docs build + # ============================================ + docs: + name: Documentation runs-on: ubuntu-latest + needs: format steps: - name: Checkout code uses: actions/checkout@v4 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable + + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxkbcommon-dev \ + libssl-dev \ + libgtk-3-dev \ + libglib2.0-dev \ + libatk1.0-dev \ + libcairo2-dev \ + libpango1.0-dev \ + libgdk-pixbuf2.0-dev \ + libwayland-dev \ + libxcb1-dev \ + libxcb-randr0-dev \ + libxcb-xkb-dev \ + libx11-xcb-dev + + - name: Cache Cargo registry + uses: actions/cache@v4 with: - components: rustfmt + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- - - name: Check formatting - run: cargo fmt --all -- --check + - name: Build documentation + run: cargo doc --no-deps --all-features + env: + RUSTDOCFLAGS: -D warnings diff --git a/src/parsers/speeduino.rs b/src/parsers/speeduino.rs index c084f18c..ff271743 100644 --- a/src/parsers/speeduino.rs +++ b/src/parsers/speeduino.rs @@ -547,3 +547,270 @@ impl Parseable for Speeduino { Err("Speeduino/rusEFI MLG files are binary format. Use parse_binary() instead.".into()) } } + +#[cfg(test)] +mod tests { + use super::*; + + // ============================================ + // FieldType Tests + // ============================================ + + #[test] + fn test_field_type_from_u8() { + assert!(matches!(FieldType::from_u8(0), Some(FieldType::U08))); + assert!(matches!(FieldType::from_u8(1), Some(FieldType::S08))); + assert!(matches!(FieldType::from_u8(2), Some(FieldType::U16))); + assert!(matches!(FieldType::from_u8(3), Some(FieldType::S16))); + assert!(matches!(FieldType::from_u8(4), Some(FieldType::U32))); + assert!(matches!(FieldType::from_u8(5), Some(FieldType::S32))); + assert!(matches!(FieldType::from_u8(6), Some(FieldType::S64))); + assert!(matches!(FieldType::from_u8(7), Some(FieldType::F32))); + assert!(matches!( + FieldType::from_u8(10), + Some(FieldType::U08Bitfield) + )); + assert!(matches!( + FieldType::from_u8(11), + Some(FieldType::U16Bitfield) + )); + assert!(matches!( + FieldType::from_u8(12), + Some(FieldType::U32Bitfield) + )); + // Invalid types + assert!(FieldType::from_u8(8).is_none()); + assert!(FieldType::from_u8(9).is_none()); + assert!(FieldType::from_u8(13).is_none()); + assert!(FieldType::from_u8(255).is_none()); + } + + #[test] + fn test_field_type_byte_size() { + assert_eq!(FieldType::U08.byte_size(), 1); + assert_eq!(FieldType::S08.byte_size(), 1); + assert_eq!(FieldType::U08Bitfield.byte_size(), 1); + assert_eq!(FieldType::U16.byte_size(), 2); + assert_eq!(FieldType::S16.byte_size(), 2); + assert_eq!(FieldType::U16Bitfield.byte_size(), 2); + assert_eq!(FieldType::U32.byte_size(), 4); + assert_eq!(FieldType::S32.byte_size(), 4); + assert_eq!(FieldType::F32.byte_size(), 4); + assert_eq!(FieldType::U32Bitfield.byte_size(), 4); + assert_eq!(FieldType::S64.byte_size(), 8); + } + + // ============================================ + // Detection Tests + // ============================================ + + #[test] + fn test_detect_valid_mlg_header() { + let valid_header = b"MLVLG\x00\x00\x01"; + assert!(Speeduino::detect(valid_header)); + } + + #[test] + fn test_detect_invalid_header() { + // Wrong magic bytes + assert!(!Speeduino::detect(b"WRONG")); + assert!(!Speeduino::detect(b"MLV")); + assert!(!Speeduino::detect(b"")); + // Too short + assert!(!Speeduino::detect(b"MLVL")); + // Completely different format + assert!(!Speeduino::detect(b"DataLog")); + assert!(!Speeduino::detect(b"%DataLog%")); + } + + #[test] + fn test_detect_exact_header_length() { + // Exactly 5 bytes with correct header should work + assert!(Speeduino::detect(b"MLVLG")); + } + + // ============================================ + // SpeeduinoChannel Tests + // ============================================ + + #[test] + fn test_speeduino_channel_unit() { + let channel = SpeeduinoChannel { + name: "RPM".to_string(), + unit: "rpm".to_string(), + scale: 1.0, + transform: 0.0, + field_type: 2, + }; + assert_eq!(channel.unit(), "rpm"); + } + + // ============================================ + // Binary Parsing Error Tests + // ============================================ + + #[test] + fn test_parse_binary_invalid_header() { + let invalid_data = b"NOT_MLG_FORMAT"; + let result = Speeduino::parse_binary(invalid_data); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Invalid MLG file header")); + } + + #[test] + fn test_parse_binary_too_short() { + // Valid header but truncated + let short_data = b"MLVLG"; + let result = Speeduino::parse_binary(short_data); + assert!(result.is_err()); + } + + #[test] + fn test_parse_binary_unreasonable_field_count() { + // Create a minimal header with unreasonable field count + let mut data = Vec::new(); + data.extend_from_slice(b"MLVLG\x00"); // Header (6 bytes) + data.extend_from_slice(&1_i16.to_be_bytes()); // Format version + data.extend_from_slice(&0_i32.to_be_bytes()); // Timestamp + data.extend_from_slice(&100_u16.to_be_bytes()); // info_data_start + data.extend_from_slice(&200_u32.to_be_bytes()); // data_begin_index + data.extend_from_slice(&10_u16.to_be_bytes()); // record_length + data.extend_from_slice(&5000_u16.to_be_bytes()); // num_fields (unreasonable) + + let result = Speeduino::parse_binary(&data); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Unreasonable field count")); + } + + // ============================================ + // Text Parser Error Test + // ============================================ + + #[test] + fn test_text_parser_returns_error() { + let parser = Speeduino; + let result = parser.parse("some text data"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("binary format")); + } + + // ============================================ + // Integration Test with Example File + // ============================================ + + #[test] + fn test_parse_speeduino_example_file() { + // Read the example Speeduino MLG file + let file_path = "exampleLogs/speeduino/speeduino.mlg"; + let data = match std::fs::read(file_path) { + Ok(d) => d, + Err(_) => { + // Skip test if example file is not available + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + // Verify detection + assert!(Speeduino::detect(&data), "Should detect as MLG format"); + + // Parse the file + let log = Speeduino::parse_binary(&data).expect("Should parse successfully"); + + // Verify basic structure + assert!(!log.channels.is_empty(), "Should have channels"); + assert!(!log.times.is_empty(), "Should have timestamps"); + assert!(!log.data.is_empty(), "Should have data records"); + + // Verify data integrity + assert_eq!( + log.times.len(), + log.data.len(), + "Times and data should have same length" + ); + + // Verify all records have correct channel count + let channel_count = log.channels.len(); + for (i, record) in log.data.iter().enumerate() { + assert_eq!( + record.len(), + channel_count, + "Record {} should have {} values", + i, + channel_count + ); + } + + // Verify timestamps are monotonically increasing (after wraparound handling) + for window in log.times.windows(2) { + assert!( + window[1] >= window[0], + "Timestamps should be monotonically increasing" + ); + } + + // Print some debug info + eprintln!("Parsed {} channels", log.channels.len()); + eprintln!("Parsed {} data records", log.data.len()); + if !log.times.is_empty() { + eprintln!( + "Time range: {:.3}s to {:.3}s", + log.times[0], + log.times[log.times.len() - 1] + ); + } + } + + #[test] + fn test_parse_rusefi_example_file() { + // Read the example rusEFI MLG file + let file_path = "exampleLogs/rusefi/rusefilog.mlg"; + let data = match std::fs::read(file_path) { + Ok(d) => d, + Err(_) => { + // Skip test if example file is not available + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + // Verify detection + assert!(Speeduino::detect(&data), "Should detect as MLG format"); + + // Parse the file + let log = Speeduino::parse_binary(&data).expect("Should parse successfully"); + + // Verify basic structure + assert!(!log.channels.is_empty(), "Should have channels"); + assert!(!log.times.is_empty(), "Should have timestamps"); + assert!(!log.data.is_empty(), "Should have data records"); + + // Verify data integrity + assert_eq!( + log.times.len(), + log.data.len(), + "Times and data should have same length" + ); + + // Verify channel names are parsed correctly (not empty/garbage) + for channel in &log.channels { + let name = channel.name(); + assert!(!name.is_empty(), "Channel name should not be empty"); + // Check that name contains printable characters + assert!( + name.chars().all(|c| c.is_ascii_graphic() || c == ' '), + "Channel name should contain valid characters: {}", + name + ); + } + + eprintln!("Parsed {} channels from rusEFI log", log.channels.len()); + eprintln!("Parsed {} data records", log.data.len()); + } +} diff --git a/src/parsers/types.rs b/src/parsers/types.rs index 7d56f38b..aa136b1e 100644 --- a/src/parsers/types.rs +++ b/src/parsers/types.rs @@ -197,3 +197,326 @@ impl EcuType { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // ============================================ + // Value Tests + // ============================================ + + #[test] + fn test_value_creation() { + let val = Value::Float(42.5); + assert_eq!(val.as_f64(), 42.5); + } + + #[test] + fn test_value_zero() { + let val = Value::Float(0.0); + assert_eq!(val.as_f64(), 0.0); + } + + #[test] + fn test_value_negative() { + let val = Value::Float(-273.15); + assert_eq!(val.as_f64(), -273.15); + } + + #[test] + fn test_value_large() { + let val = Value::Float(1_000_000.0); + assert_eq!(val.as_f64(), 1_000_000.0); + } + + #[test] + fn test_value_small() { + let val = Value::Float(0.000001); + assert!((val.as_f64() - 0.000001).abs() < 1e-10); + } + + #[test] + fn test_value_default() { + let val = Value::default(); + assert_eq!(val.as_f64(), 0.0); + } + + #[test] + fn test_value_copy() { + let val1 = Value::Float(100.0); + let val2 = val1; // Copy + assert_eq!(val1.as_f64(), val2.as_f64()); + } + + #[test] + fn test_value_clone() { + let val1 = Value::Float(100.0); + #[allow(clippy::clone_on_copy)] + let val2 = val1.clone(); // Testing Clone trait specifically + assert_eq!(val1.as_f64(), val2.as_f64()); + } + + // ============================================ + // Log Tests + // ============================================ + + #[test] + fn test_log_default() { + let log = Log::default(); + assert!(log.channels.is_empty()); + assert!(log.times.is_empty()); + assert!(log.data.is_empty()); + } + + #[test] + fn test_log_get_channel_data() { + let log = Log { + meta: Meta::Empty, + channels: vec![], + times: vec![0.0, 1.0, 2.0], + data: vec![ + vec![Value::Float(100.0), Value::Float(200.0)], + vec![Value::Float(110.0), Value::Float(210.0)], + vec![Value::Float(120.0), Value::Float(220.0)], + ], + }; + + let channel0 = log.get_channel_data(0); + assert_eq!(channel0, vec![100.0, 110.0, 120.0]); + + let channel1 = log.get_channel_data(1); + assert_eq!(channel1, vec![200.0, 210.0, 220.0]); + } + + #[test] + fn test_log_get_channel_data_out_of_bounds() { + let log = Log { + meta: Meta::Empty, + channels: vec![], + times: vec![0.0, 1.0], + data: vec![vec![Value::Float(100.0)], vec![Value::Float(110.0)]], + }; + + // Out of bounds should return empty + let channel_oob = log.get_channel_data(5); + assert!(channel_oob.is_empty()); + } + + #[test] + fn test_log_get_times_as_f64() { + let log = Log { + meta: Meta::Empty, + channels: vec![], + times: vec![0.0, 0.5, 1.0, 1.5, 2.0], + data: vec![], + }; + + let times = log.get_times_as_f64(); + assert_eq!(times.len(), 5); + assert_eq!(times[0], 0.0); + assert_eq!(times[2], 1.0); + assert_eq!(times[4], 2.0); + } + + #[test] + fn test_log_find_channel_index() { + use super::super::haltech::{ChannelType, HaltechChannel}; + + let log = Log { + meta: Meta::Empty, + channels: vec![ + Channel::Haltech(HaltechChannel { + name: "RPM".to_string(), + id: "1".to_string(), + r#type: ChannelType::EngineSpeed, + display_min: None, + display_max: None, + }), + Channel::Haltech(HaltechChannel { + name: "Manifold Pressure".to_string(), + id: "2".to_string(), + r#type: ChannelType::Pressure, + display_min: None, + display_max: None, + }), + Channel::Haltech(HaltechChannel { + name: "Coolant Temp".to_string(), + id: "3".to_string(), + r#type: ChannelType::Temperature, + display_min: None, + display_max: None, + }), + ], + times: vec![], + data: vec![], + }; + + assert_eq!(log.find_channel_index("RPM"), Some(0)); + assert_eq!(log.find_channel_index("Manifold Pressure"), Some(1)); + assert_eq!(log.find_channel_index("Coolant Temp"), Some(2)); + assert_eq!(log.find_channel_index("Not Found"), None); + } + + // ============================================ + // Channel Tests + // ============================================ + + #[test] + fn test_channel_name_haltech() { + use super::super::haltech::{ChannelType, HaltechChannel}; + + let channel = Channel::Haltech(HaltechChannel { + name: "Engine RPM".to_string(), + id: "123".to_string(), + r#type: ChannelType::EngineSpeed, + display_min: None, + display_max: None, + }); + + assert_eq!(channel.name(), "Engine RPM"); + } + + #[test] + fn test_channel_id_haltech() { + use super::super::haltech::{ChannelType, HaltechChannel}; + + let channel = Channel::Haltech(HaltechChannel { + name: "Engine RPM".to_string(), + id: "123".to_string(), + r#type: ChannelType::EngineSpeed, + display_min: None, + display_max: None, + }); + + assert_eq!(channel.id(), "123"); + } + + #[test] + fn test_channel_type_name_haltech() { + use super::super::haltech::{ChannelType, HaltechChannel}; + + let channel = Channel::Haltech(HaltechChannel { + name: "Engine RPM".to_string(), + id: "123".to_string(), + r#type: ChannelType::EngineSpeed, + display_min: None, + display_max: None, + }); + + assert_eq!(channel.type_name(), "EngineSpeed"); + } + + #[test] + fn test_channel_display_min_max_haltech() { + use super::super::haltech::{ChannelType, HaltechChannel}; + + let channel_with_bounds = Channel::Haltech(HaltechChannel { + name: "RPM".to_string(), + id: "1".to_string(), + r#type: ChannelType::EngineSpeed, + display_min: Some(0.0), + display_max: Some(10000.0), + }); + + assert_eq!(channel_with_bounds.display_min(), Some(0.0)); + assert_eq!(channel_with_bounds.display_max(), Some(10000.0)); + + let channel_without_bounds = Channel::Haltech(HaltechChannel { + name: "RPM".to_string(), + id: "1".to_string(), + r#type: ChannelType::EngineSpeed, + display_min: None, + display_max: None, + }); + + assert_eq!(channel_without_bounds.display_min(), None); + assert_eq!(channel_without_bounds.display_max(), None); + } + + #[test] + fn test_channel_unit_haltech() { + use super::super::haltech::{ChannelType, HaltechChannel}; + + let rpm_channel = Channel::Haltech(HaltechChannel { + name: "RPM".to_string(), + id: "1".to_string(), + r#type: ChannelType::EngineSpeed, + display_min: None, + display_max: None, + }); + assert_eq!(rpm_channel.unit(), "RPM"); + + let temp_channel = Channel::Haltech(HaltechChannel { + name: "Coolant".to_string(), + id: "2".to_string(), + r#type: ChannelType::Temperature, + display_min: None, + display_max: None, + }); + assert_eq!(temp_channel.unit(), "K"); + + let pressure_channel = Channel::Haltech(HaltechChannel { + name: "MAP".to_string(), + id: "3".to_string(), + r#type: ChannelType::Pressure, + display_min: None, + display_max: None, + }); + assert_eq!(pressure_channel.unit(), "kPa"); + } + + // ============================================ + // EcuType Tests + // ============================================ + + #[test] + fn test_ecu_type_names() { + assert_eq!(EcuType::Haltech.name(), "Haltech"); + assert_eq!(EcuType::EcuMaster.name(), "ECUMaster"); + assert_eq!(EcuType::MegaSquirt.name(), "MegaSquirt"); + assert_eq!(EcuType::Aem.name(), "AEM"); + assert_eq!(EcuType::MaxxEcu.name(), "MaxxECU"); + assert_eq!(EcuType::MotEc.name(), "MoTeC"); + assert_eq!(EcuType::Link.name(), "Link"); + assert_eq!(EcuType::RomRaider.name(), "RomRaider"); + assert_eq!(EcuType::Speeduino.name(), "Speeduino/rusEFI"); + assert_eq!(EcuType::Unknown.name(), "Unknown"); + } + + #[test] + fn test_ecu_type_default() { + let default = EcuType::default(); + assert_eq!(default, EcuType::Haltech); + } + + #[test] + fn test_ecu_type_equality() { + assert_eq!(EcuType::Haltech, EcuType::Haltech); + assert_ne!(EcuType::Haltech, EcuType::EcuMaster); + } + + #[test] + fn test_ecu_type_copy() { + let ecu1 = EcuType::Speeduino; + let ecu2 = ecu1; // Copy + assert_eq!(ecu1, ecu2); + } + + // ============================================ + // Meta Tests + // ============================================ + + #[test] + fn test_meta_default() { + let meta = Meta::default(); + assert!(matches!(meta, Meta::Empty)); + } + + #[test] + fn test_meta_clone() { + let meta = Meta::Empty; + let meta_clone = meta.clone(); + assert!(matches!(meta_clone, Meta::Empty)); + } +} diff --git a/src/units.rs b/src/units.rs index c452336e..520e3a4b 100644 --- a/src/units.rs +++ b/src/units.rs @@ -276,3 +276,364 @@ impl UnitPreferences { } } } + +#[cfg(test)] +mod tests { + use super::*; + + // ============================================ + // Temperature Unit Tests + // ============================================ + + #[test] + fn test_temperature_kelvin_identity() { + let unit = TemperatureUnit::Kelvin; + assert_eq!(unit.convert_from_kelvin(273.15), 273.15); + assert_eq!(unit.convert_from_kelvin(0.0), 0.0); + assert_eq!(unit.convert_from_kelvin(373.15), 373.15); + } + + #[test] + fn test_temperature_kelvin_to_celsius() { + let unit = TemperatureUnit::Celsius; + // 0°C = 273.15K + assert!((unit.convert_from_kelvin(273.15) - 0.0).abs() < 0.001); + // 100°C = 373.15K + assert!((unit.convert_from_kelvin(373.15) - 100.0).abs() < 0.001); + // -40°C = 233.15K + assert!((unit.convert_from_kelvin(233.15) - (-40.0)).abs() < 0.001); + // Absolute zero + assert!((unit.convert_from_kelvin(0.0) - (-273.15)).abs() < 0.001); + } + + #[test] + fn test_temperature_kelvin_to_fahrenheit() { + let unit = TemperatureUnit::Fahrenheit; + // 32°F = 273.15K (0°C) + assert!((unit.convert_from_kelvin(273.15) - 32.0).abs() < 0.001); + // 212°F = 373.15K (100°C) + assert!((unit.convert_from_kelvin(373.15) - 212.0).abs() < 0.001); + // -40°F = -40°C = 233.15K + assert!((unit.convert_from_kelvin(233.15) - (-40.0)).abs() < 0.001); + } + + #[test] + fn test_temperature_symbols() { + assert_eq!(TemperatureUnit::Kelvin.symbol(), "K"); + assert_eq!(TemperatureUnit::Celsius.symbol(), "°C"); + assert_eq!(TemperatureUnit::Fahrenheit.symbol(), "°F"); + } + + // ============================================ + // Pressure Unit Tests + // ============================================ + + #[test] + fn test_pressure_kpa_identity() { + let unit = PressureUnit::KPa; + assert_eq!(unit.convert_from_kpa(101.325), 101.325); + assert_eq!(unit.convert_from_kpa(0.0), 0.0); + assert_eq!(unit.convert_from_kpa(200.0), 200.0); + } + + #[test] + fn test_pressure_kpa_to_psi() { + let unit = PressureUnit::PSI; + // 1 kPa ≈ 0.145038 PSI + assert!((unit.convert_from_kpa(1.0) - 0.145038).abs() < 0.0001); + // Atmospheric pressure: 101.325 kPa ≈ 14.696 PSI + assert!((unit.convert_from_kpa(101.325) - 14.696).abs() < 0.01); + // 100 kPa ≈ 14.5 PSI + assert!((unit.convert_from_kpa(100.0) - 14.5038).abs() < 0.01); + } + + #[test] + fn test_pressure_kpa_to_bar() { + let unit = PressureUnit::Bar; + // 100 kPa = 1 bar + assert!((unit.convert_from_kpa(100.0) - 1.0).abs() < 0.001); + // 101.325 kPa = 1.01325 bar (atmospheric) + assert!((unit.convert_from_kpa(101.325) - 1.01325).abs() < 0.001); + // 200 kPa = 2 bar + assert!((unit.convert_from_kpa(200.0) - 2.0).abs() < 0.001); + } + + #[test] + fn test_pressure_symbols() { + assert_eq!(PressureUnit::KPa.symbol(), "kPa"); + assert_eq!(PressureUnit::PSI.symbol(), "PSI"); + assert_eq!(PressureUnit::Bar.symbol(), "bar"); + } + + // ============================================ + // Speed Unit Tests + // ============================================ + + #[test] + fn test_speed_kmh_identity() { + let unit = SpeedUnit::KmH; + assert_eq!(unit.convert_from_kmh(100.0), 100.0); + assert_eq!(unit.convert_from_kmh(0.0), 0.0); + } + + #[test] + fn test_speed_kmh_to_mph() { + let unit = SpeedUnit::Mph; + // 100 km/h ≈ 62.14 mph + assert!((unit.convert_from_kmh(100.0) - 62.1371).abs() < 0.001); + // 160 km/h ≈ 99.42 mph + assert!((unit.convert_from_kmh(160.0) - 99.4194).abs() < 0.01); + } + + #[test] + fn test_speed_symbols() { + assert_eq!(SpeedUnit::KmH.symbol(), "km/h"); + assert_eq!(SpeedUnit::Mph.symbol(), "mph"); + } + + // ============================================ + // Distance Unit Tests + // ============================================ + + #[test] + fn test_distance_km_identity() { + let unit = DistanceUnit::Kilometers; + assert_eq!(unit.convert_from_km(100.0), 100.0); + assert_eq!(unit.convert_from_km(0.0), 0.0); + } + + #[test] + fn test_distance_km_to_miles() { + let unit = DistanceUnit::Miles; + // 100 km ≈ 62.14 miles + assert!((unit.convert_from_km(100.0) - 62.1371).abs() < 0.001); + // 1.60934 km ≈ 1 mile + assert!((unit.convert_from_km(1.60934) - 1.0).abs() < 0.001); + } + + #[test] + fn test_distance_symbols() { + assert_eq!(DistanceUnit::Kilometers.symbol(), "km"); + assert_eq!(DistanceUnit::Miles.symbol(), "mi"); + } + + // ============================================ + // Fuel Economy Unit Tests + // ============================================ + + #[test] + fn test_fuel_economy_l_per_100km_identity() { + let unit = FuelEconomyUnit::LPer100Km; + assert_eq!(unit.convert_from_l_per_100km(10.0), 10.0); + assert_eq!(unit.convert_from_l_per_100km(5.0), 5.0); + } + + #[test] + fn test_fuel_economy_l_per_100km_to_mpg() { + let unit = FuelEconomyUnit::Mpg; + // 10 L/100km ≈ 23.52 mpg + assert!((unit.convert_from_l_per_100km(10.0) - 23.5215).abs() < 0.01); + // 5 L/100km ≈ 47.04 mpg + assert!((unit.convert_from_l_per_100km(5.0) - 47.043).abs() < 0.01); + // Edge case: 0 L/100km should return 0 (not divide by zero) + assert_eq!(unit.convert_from_l_per_100km(0.0), 0.0); + } + + #[test] + fn test_fuel_economy_l_per_100km_to_km_per_l() { + let unit = FuelEconomyUnit::KmPerL; + // 10 L/100km = 10 km/L + assert!((unit.convert_from_l_per_100km(10.0) - 10.0).abs() < 0.001); + // 5 L/100km = 20 km/L + assert!((unit.convert_from_l_per_100km(5.0) - 20.0).abs() < 0.001); + // Edge case: 0 L/100km should return 0 + assert_eq!(unit.convert_from_l_per_100km(0.0), 0.0); + } + + #[test] + fn test_fuel_economy_symbols() { + assert_eq!(FuelEconomyUnit::LPer100Km.symbol(), "L/100km"); + assert_eq!(FuelEconomyUnit::Mpg.symbol(), "mpg"); + assert_eq!(FuelEconomyUnit::KmPerL.symbol(), "km/L"); + } + + // ============================================ + // Volume Unit Tests + // ============================================ + + #[test] + fn test_volume_liters_identity() { + let unit = VolumeUnit::Liters; + assert_eq!(unit.convert_from_liters(100.0), 100.0); + assert_eq!(unit.convert_from_liters(0.0), 0.0); + } + + #[test] + fn test_volume_liters_to_gallons() { + let unit = VolumeUnit::Gallons; + // 1 L ≈ 0.264172 gallons + assert!((unit.convert_from_liters(1.0) - 0.264172).abs() < 0.0001); + // 3.78541 L ≈ 1 gallon + assert!((unit.convert_from_liters(3.78541) - 1.0).abs() < 0.001); + } + + #[test] + fn test_volume_symbols() { + assert_eq!(VolumeUnit::Liters.symbol(), "L"); + assert_eq!(VolumeUnit::Gallons.symbol(), "gal"); + } + + // ============================================ + // Flow Rate Unit Tests + // ============================================ + + #[test] + fn test_flow_cc_per_min_identity() { + let unit = FlowUnit::CcPerMin; + assert_eq!(unit.convert_from_cc_per_min(100.0), 100.0); + assert_eq!(unit.convert_from_cc_per_min(0.0), 0.0); + } + + #[test] + fn test_flow_cc_per_min_to_lb_per_hr() { + let unit = FlowUnit::LbPerHr; + // Formula: cc/min * 0.75 g/cc * 60 min/hr / 453.592 g/lb + // 100 cc/min ≈ 9.92 lb/hr + let result = unit.convert_from_cc_per_min(100.0); + let expected = 100.0 * 0.75 * 60.0 / 453.592; + assert!((result - expected).abs() < 0.01); + } + + #[test] + fn test_flow_symbols() { + assert_eq!(FlowUnit::CcPerMin.symbol(), "cc/min"); + assert_eq!(FlowUnit::LbPerHr.symbol(), "lb/hr"); + } + + // ============================================ + // Acceleration Unit Tests + // ============================================ + + #[test] + fn test_acceleration_m_per_s2_identity() { + let unit = AccelerationUnit::MPerS2; + assert_eq!(unit.convert_from_m_per_s2(9.80665), 9.80665); + assert_eq!(unit.convert_from_m_per_s2(0.0), 0.0); + } + + #[test] + fn test_acceleration_m_per_s2_to_g() { + let unit = AccelerationUnit::G; + // 1g = 9.80665 m/s² + assert!((unit.convert_from_m_per_s2(9.80665) - 1.0).abs() < 0.0001); + // 19.6133 m/s² = 2g + assert!((unit.convert_from_m_per_s2(19.6133) - 2.0).abs() < 0.001); + } + + #[test] + fn test_acceleration_symbols() { + assert_eq!(AccelerationUnit::MPerS2.symbol(), "m/s²"); + assert_eq!(AccelerationUnit::G.symbol(), "g"); + } + + // ============================================ + // UnitPreferences Tests + // ============================================ + + #[test] + fn test_unit_preferences_default() { + let prefs = UnitPreferences::default(); + assert_eq!(prefs.temperature, TemperatureUnit::Celsius); + assert_eq!(prefs.pressure, PressureUnit::KPa); + assert_eq!(prefs.speed, SpeedUnit::KmH); + assert_eq!(prefs.distance, DistanceUnit::Kilometers); + assert_eq!(prefs.fuel_economy, FuelEconomyUnit::LPer100Km); + assert_eq!(prefs.volume, VolumeUnit::Liters); + assert_eq!(prefs.flow, FlowUnit::CcPerMin); + assert_eq!(prefs.acceleration, AccelerationUnit::MPerS2); + } + + #[test] + fn test_unit_preferences_convert_temperature() { + let mut prefs = UnitPreferences::default(); + + // Default: Celsius + let (value, unit) = prefs.convert_value(293.15, "K"); + assert!((value - 20.0).abs() < 0.01); + assert_eq!(unit, "°C"); + + // Switch to Fahrenheit + prefs.temperature = TemperatureUnit::Fahrenheit; + let (value, unit) = prefs.convert_value(293.15, "K"); + assert!((value - 68.0).abs() < 0.1); + assert_eq!(unit, "°F"); + } + + #[test] + fn test_unit_preferences_convert_pressure() { + let mut prefs = UnitPreferences::default(); + + // Default: kPa + let (value, unit) = prefs.convert_value(101.325, "kPa"); + assert!((value - 101.325).abs() < 0.001); + assert_eq!(unit, "kPa"); + + // Switch to PSI + prefs.pressure = PressureUnit::PSI; + let (value, unit) = prefs.convert_value(101.325, "kPa"); + assert!((value - 14.696).abs() < 0.01); + assert_eq!(unit, "PSI"); + } + + #[test] + fn test_unit_preferences_unknown_unit_passthrough() { + let prefs = UnitPreferences::default(); + // Unknown units should pass through unchanged + let (value, unit) = prefs.convert_value(42.0, "RPM"); + assert_eq!(value, 42.0); + assert_eq!(unit, "RPM"); + + let (value, unit) = prefs.convert_value(14.7, "AFR"); + assert_eq!(value, 14.7); + assert_eq!(unit, "AFR"); + } + + #[test] + fn test_unit_preferences_all_conversions() { + let prefs = UnitPreferences { + temperature: TemperatureUnit::Fahrenheit, + pressure: PressureUnit::PSI, + speed: SpeedUnit::Mph, + distance: DistanceUnit::Miles, + fuel_economy: FuelEconomyUnit::Mpg, + volume: VolumeUnit::Gallons, + flow: FlowUnit::LbPerHr, + acceleration: AccelerationUnit::G, + }; + + // Test each conversion + let (_, unit) = prefs.convert_value(300.0, "K"); + assert_eq!(unit, "°F"); + + let (_, unit) = prefs.convert_value(100.0, "kPa"); + assert_eq!(unit, "PSI"); + + let (_, unit) = prefs.convert_value(100.0, "km/h"); + assert_eq!(unit, "mph"); + + let (_, unit) = prefs.convert_value(100.0, "km"); + assert_eq!(unit, "mi"); + + let (_, unit) = prefs.convert_value(10.0, "L/100km"); + assert_eq!(unit, "mpg"); + + let (_, unit) = prefs.convert_value(50.0, "L"); + assert_eq!(unit, "gal"); + + let (_, unit) = prefs.convert_value(100.0, "cc/min"); + assert_eq!(unit, "lb/hr"); + + let (_, unit) = prefs.convert_value(9.8, "m/s²"); + assert_eq!(unit, "g"); + } +} diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs new file mode 100644 index 00000000..4bb4a24d --- /dev/null +++ b/tests/integration_tests.rs @@ -0,0 +1,435 @@ +//! Integration tests for UltraLog parser system +//! +//! These tests verify end-to-end parsing of example log files +//! from various ECU formats supported by UltraLog. + +use ultralog::parsers::ecumaster::EcuMaster; +use ultralog::parsers::haltech::Haltech; +use ultralog::parsers::romraider::RomRaider; +use ultralog::parsers::speeduino::Speeduino; +use ultralog::parsers::types::Parseable; + +// ============================================ +// Haltech Integration Tests +// ============================================ + +#[test] +fn test_haltech_example_log_parsing() { + let file_path = "exampleLogs/haltech/2025-07-18_0215pm_Log1118.csv"; + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + let parser = Haltech; + let log = parser.parse(&content).expect("Should parse Haltech log"); + + // Verify structure + assert!(!log.channels.is_empty(), "Should have channels"); + assert!(!log.times.is_empty(), "Should have timestamps"); + assert!(!log.data.is_empty(), "Should have data records"); + + // Verify data integrity + assert_eq!( + log.times.len(), + log.data.len(), + "Times and data should have same length" + ); + + // All records should have same number of values as channels + let channel_count = log.channels.len(); + for record in &log.data { + assert_eq!( + record.len(), + channel_count, + "Each record should have {} values", + channel_count + ); + } + + // Timestamps should be monotonically increasing (relative to first) + let times = log.get_times_as_f64(); + for window in times.windows(2) { + assert!( + window[1] >= window[0], + "Timestamps should be monotonically increasing" + ); + } + + eprintln!( + "Haltech log: {} channels, {} records, time range: {:.2}s to {:.2}s", + log.channels.len(), + log.data.len(), + times.first().unwrap_or(&0.0), + times.last().unwrap_or(&0.0) + ); +} + +#[test] +fn test_haltech_multi_log_file() { + // This file contains multiple logs + let file_path = "exampleLogs/haltech/2025-03-06_0937pm_Logs658to874.csv"; + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + let parser = Haltech; + let log = parser + .parse(&content) + .expect("Should parse multi-log Haltech file"); + + assert!(!log.channels.is_empty(), "Should have channels"); + assert!(!log.data.is_empty(), "Should have data records"); + + eprintln!( + "Haltech multi-log: {} channels, {} records", + log.channels.len(), + log.data.len() + ); +} + +// ============================================ +// ECUMaster Integration Tests +// ============================================ + +#[test] +fn test_ecumaster_example_log_parsing() { + let file_path = "exampleLogs/ecumaster/2025_1218_1903.csv"; + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + // First verify detection + assert!( + EcuMaster::detect(&content), + "Should detect as ECUMaster format" + ); + + let parser = EcuMaster; + let log = parser.parse(&content).expect("Should parse ECUMaster log"); + + assert!(!log.channels.is_empty(), "Should have channels"); + assert!(!log.times.is_empty(), "Should have timestamps"); + assert!(!log.data.is_empty(), "Should have data records"); + + // Verify data integrity + assert_eq!( + log.times.len(), + log.data.len(), + "Times and data should have same length" + ); + + eprintln!( + "ECUMaster log: {} channels, {} records", + log.channels.len(), + log.data.len() + ); +} + +#[test] +fn test_ecumaster_large_file() { + let file_path = "exampleLogs/ecumaster/Largest.csv"; + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + if !EcuMaster::detect(&content) { + eprintln!("File doesn't match ECUMaster format, skipping"); + return; + } + + let parser = EcuMaster; + let log = parser + .parse(&content) + .expect("Should parse large ECUMaster log"); + + assert!(!log.channels.is_empty(), "Should have channels"); + assert!(!log.data.is_empty(), "Should have data records"); + + eprintln!( + "ECUMaster large log: {} channels, {} records", + log.channels.len(), + log.data.len() + ); +} + +// ============================================ +// RomRaider Integration Tests +// ============================================ + +#[test] +fn test_romraider_detection() { + // Test that RomRaider format is correctly detected vs others + let haltech_sample = "%DataLog%\nDataLogVersion : 1.1\n"; + let ecumaster_sample = "Time;RPM;MAP\n0.0;1000;50\n"; + + assert!( + !RomRaider::detect(haltech_sample), + "Should not detect Haltech as RomRaider" + ); + assert!( + !RomRaider::detect(ecumaster_sample), + "Should not detect ECUMaster as RomRaider" + ); +} + +// ============================================ +// Speeduino/rusEFI Integration Tests +// ============================================ + +#[test] +fn test_speeduino_mlg_parsing() { + let file_path = "exampleLogs/speeduino/speeduino.mlg"; + let data = match std::fs::read(file_path) { + Ok(d) => d, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + assert!(Speeduino::detect(&data), "Should detect as MLG format"); + + let log = Speeduino::parse_binary(&data).expect("Should parse Speeduino MLG"); + + assert!(!log.channels.is_empty(), "Should have channels"); + assert!(!log.times.is_empty(), "Should have timestamps"); + assert!(!log.data.is_empty(), "Should have data records"); + + // Verify data integrity + assert_eq!( + log.times.len(), + log.data.len(), + "Times and data should have same length" + ); + + // Verify all records have correct number of values + let channel_count = log.channels.len(); + for (i, record) in log.data.iter().enumerate() { + assert_eq!( + record.len(), + channel_count, + "Record {} should have {} values", + i, + channel_count + ); + } + + eprintln!( + "Speeduino log: {} channels, {} records", + log.channels.len(), + log.data.len() + ); +} + +#[test] +fn test_rusefi_mlg_parsing() { + let file_path = "exampleLogs/rusefi/rusefilog.mlg"; + let data = match std::fs::read(file_path) { + Ok(d) => d, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + assert!(Speeduino::detect(&data), "Should detect as MLG format"); + + let log = Speeduino::parse_binary(&data).expect("Should parse rusEFI MLG"); + + assert!(!log.channels.is_empty(), "Should have channels"); + assert!(!log.times.is_empty(), "Should have timestamps"); + assert!(!log.data.is_empty(), "Should have data records"); + + // rusEFI logs can be large - verify we parsed a reasonable amount + assert!(log.data.len() > 100, "Should have substantial data records"); + + eprintln!( + "rusEFI log: {} channels, {} records", + log.channels.len(), + log.data.len() + ); +} + +// ============================================ +// Cross-Format Tests +// ============================================ + +#[test] +fn test_format_detection_mutual_exclusion() { + // Haltech format markers + let haltech_data = "%DataLog%\nDataLogVersion : 1.1\n"; + + // ECUMaster uses semicolon-separated CSV starting with "TIME" + let ecumaster_data = "TIME;Engine speed\n0.0;1000\n"; + + // MLG binary format + let mlg_data = b"MLVLG\x00\x00\x01"; + + // Haltech detection + assert!( + haltech_data.starts_with("%DataLog%"), + "Haltech marker should be present" + ); + + // ECUMaster detection + assert!( + EcuMaster::detect(ecumaster_data), + "ECUMaster format should be detected" + ); + assert!( + !EcuMaster::detect(haltech_data), + "Haltech should not be detected as ECUMaster" + ); + + // Speeduino/MLG detection + assert!(Speeduino::detect(mlg_data), "MLG format should be detected"); + assert!( + !Speeduino::detect(haltech_data.as_bytes()), + "Haltech should not be detected as MLG" + ); +} + +// ============================================ +// Channel Data Access Tests +// ============================================ + +#[test] +fn test_channel_data_extraction() { + let file_path = "exampleLogs/haltech/2025-07-18_0215pm_Log1118.csv"; + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + let parser = Haltech; + let log = parser.parse(&content).expect("Should parse log"); + + // Test get_channel_data for each channel + for (idx, channel) in log.channels.iter().enumerate() { + let data = log.get_channel_data(idx); + assert_eq!( + data.len(), + log.data.len(), + "Channel {} ({}) data length should match record count", + idx, + channel.name() + ); + } + + // Test out of bounds access + let oob_data = log.get_channel_data(999); + assert!( + oob_data.is_empty(), + "Out of bounds access should return empty" + ); +} + +#[test] +fn test_find_channel_index() { + let file_path = "exampleLogs/haltech/2025-07-18_0215pm_Log1118.csv"; + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + let parser = Haltech; + let log = parser.parse(&content).expect("Should parse log"); + + // Test finding channels that exist + if !log.channels.is_empty() { + let first_channel_name = log.channels[0].name(); + let found_idx = log.find_channel_index(&first_channel_name); + assert_eq!(found_idx, Some(0), "Should find first channel at index 0"); + } + + // Test finding channel that doesn't exist + let not_found = log.find_channel_index("NonExistentChannel12345"); + assert_eq!( + not_found, None, + "Should return None for non-existent channel" + ); +} + +// ============================================ +// Time Range Tests +// ============================================ + +#[test] +fn test_time_range_validity() { + let file_path = "exampleLogs/speeduino/speeduino.mlg"; + let data = match std::fs::read(file_path) { + Ok(d) => d, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + let log = Speeduino::parse_binary(&data).expect("Should parse log"); + let times = log.get_times_as_f64(); + + // Verify first timestamp is non-negative + if !times.is_empty() { + assert!(times[0] >= 0.0, "First timestamp should be non-negative"); + } + + // Verify all timestamps are finite + for (i, &t) in times.iter().enumerate() { + assert!(t.is_finite(), "Timestamp {} should be finite", i); + } +} + +// ============================================ +// Data Integrity Tests +// ============================================ + +#[test] +fn test_data_values_are_finite() { + let file_path = "exampleLogs/haltech/2025-07-18_0215pm_Log1118.csv"; + let content = match std::fs::read_to_string(file_path) { + Ok(c) => c, + Err(_) => { + eprintln!("Skipping test: {} not found", file_path); + return; + } + }; + + let parser = Haltech; + let log = parser.parse(&content).expect("Should parse log"); + + // Verify all data values are finite (not NaN or Infinity) + for (row_idx, row) in log.data.iter().enumerate() { + for (col_idx, value) in row.iter().enumerate() { + let f = value.as_f64(); + assert!( + f.is_finite(), + "Value at row {}, col {} should be finite, got {}", + row_idx, + col_idx, + f + ); + } + } +} From 595dc151f3dea1ca5fbd2633c06ab0028759759b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Dec 2025 18:35:51 +0000 Subject: [PATCH 2/3] Address PR review comments on integration tests - Add helper functions (read_example_file, read_example_binary) that panic with clear error messages instead of silently skipping tests - Add positive RomRaider detection tests and full test_romraider_parsing() integration test with synthetic data - Change ECUMaster large file test to assert on format detection - Add RomRaider to cross-format mutual exclusion test - All tests now fail loudly when example files are missing --- tests/integration_tests.rs | 218 ++++++++++++++++++++++++------------- 1 file changed, 142 insertions(+), 76 deletions(-) diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 4bb4a24d..d5213132 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -9,6 +9,19 @@ use ultralog::parsers::romraider::RomRaider; use ultralog::parsers::speeduino::Speeduino; use ultralog::parsers::types::Parseable; +/// Helper function to read a file, panicking with a clear message if not found. +/// This ensures CI catches missing example files instead of silently skipping tests. +fn read_example_file(file_path: &str) -> String { + std::fs::read_to_string(file_path) + .unwrap_or_else(|e| panic!("Failed to read example file '{}': {}", file_path, e)) +} + +/// Helper function to read a binary file, panicking with a clear message if not found. +fn read_example_binary(file_path: &str) -> Vec { + std::fs::read(file_path) + .unwrap_or_else(|e| panic!("Failed to read example file '{}': {}", file_path, e)) +} + // ============================================ // Haltech Integration Tests // ============================================ @@ -16,13 +29,7 @@ use ultralog::parsers::types::Parseable; #[test] fn test_haltech_example_log_parsing() { let file_path = "exampleLogs/haltech/2025-07-18_0215pm_Log1118.csv"; - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let content = read_example_file(file_path); let parser = Haltech; let log = parser.parse(&content).expect("Should parse Haltech log"); @@ -72,13 +79,7 @@ fn test_haltech_example_log_parsing() { fn test_haltech_multi_log_file() { // This file contains multiple logs let file_path = "exampleLogs/haltech/2025-03-06_0937pm_Logs658to874.csv"; - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let content = read_example_file(file_path); let parser = Haltech; let log = parser @@ -102,13 +103,7 @@ fn test_haltech_multi_log_file() { #[test] fn test_ecumaster_example_log_parsing() { let file_path = "exampleLogs/ecumaster/2025_1218_1903.csv"; - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let content = read_example_file(file_path); // First verify detection assert!( @@ -140,18 +135,14 @@ fn test_ecumaster_example_log_parsing() { #[test] fn test_ecumaster_large_file() { let file_path = "exampleLogs/ecumaster/Largest.csv"; - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let content = read_example_file(file_path); - if !EcuMaster::detect(&content) { - eprintln!("File doesn't match ECUMaster format, skipping"); - return; - } + // Format detection should always succeed for known ECUMaster files + assert!( + EcuMaster::detect(&content), + "File '{}' should be detected as ECUMaster format", + file_path + ); let parser = EcuMaster; let log = parser @@ -174,9 +165,22 @@ fn test_ecumaster_large_file() { #[test] fn test_romraider_detection() { - // Test that RomRaider format is correctly detected vs others + // Test positive detection - valid RomRaider samples + let valid_romraider_simple = "Time,RPM,Load\n0,1000,50\n"; + let valid_romraider_with_units = "Time (msec),Engine Speed (rpm),Engine Load (%)\n0,1000,50\n"; + + assert!( + RomRaider::detect(valid_romraider_simple), + "Should detect simple RomRaider format" + ); + assert!( + RomRaider::detect(valid_romraider_with_units), + "Should detect RomRaider format with units in headers" + ); + + // Test negative detection - other formats should not be detected as RomRaider let haltech_sample = "%DataLog%\nDataLogVersion : 1.1\n"; - let ecumaster_sample = "Time;RPM;MAP\n0.0;1000;50\n"; + let ecumaster_sample = "TIME;RPM;MAP\n0.0;1000;50\n"; assert!( !RomRaider::detect(haltech_sample), @@ -188,6 +192,83 @@ fn test_romraider_detection() { ); } +#[test] +fn test_romraider_parsing() { + // Test with synthetic RomRaider data since no example file exists + let sample_data = + "Time (msec),Engine Speed (rpm),Engine Load (%),Coolant Temp (C),Battery Voltage (V)\n\ + 0,850,15.5,85.0,14.2\n\ + 20,900,18.0,85.5,14.1\n\ + 40,950,20.5,86.0,14.3\n\ + 60,1000,22.0,86.5,14.2\n"; + + // Verify detection + assert!( + RomRaider::detect(sample_data), + "Should detect as RomRaider format" + ); + + // Parse the data + let parser = RomRaider; + let log = parser + .parse(sample_data) + .expect("Should parse RomRaider log"); + + // Verify structure + assert_eq!(log.channels.len(), 4, "Should have 4 channels"); + assert_eq!(log.times.len(), 4, "Should have 4 timestamps"); + assert_eq!(log.data.len(), 4, "Should have 4 data records"); + + // Verify channel names (units should be stripped) + assert_eq!(log.channels[0].name(), "Engine Speed"); + assert_eq!(log.channels[1].name(), "Engine Load"); + assert_eq!(log.channels[2].name(), "Coolant Temp"); + assert_eq!(log.channels[3].name(), "Battery Voltage"); + + // Verify timestamps (converted from ms to seconds) + let times = log.get_times_as_f64(); + assert!( + (times[0] - 0.0).abs() < 0.001, + "First timestamp should be 0" + ); + assert!( + (times[1] - 0.020).abs() < 0.001, + "Second timestamp should be 0.020" + ); + assert!( + (times[3] - 0.060).abs() < 0.001, + "Fourth timestamp should be 0.060" + ); + + // Verify data values + assert_eq!(log.data[0][0].as_f64(), 850.0); + assert_eq!(log.data[3][0].as_f64(), 1000.0); + + // Verify data integrity + assert_eq!( + log.times.len(), + log.data.len(), + "Times and data should have same length" + ); + + let channel_count = log.channels.len(); + for (i, record) in log.data.iter().enumerate() { + assert_eq!( + record.len(), + channel_count, + "Record {} should have {} values", + i, + channel_count + ); + } + + eprintln!( + "RomRaider log: {} channels, {} records", + log.channels.len(), + log.data.len() + ); +} + // ============================================ // Speeduino/rusEFI Integration Tests // ============================================ @@ -195,13 +276,7 @@ fn test_romraider_detection() { #[test] fn test_speeduino_mlg_parsing() { let file_path = "exampleLogs/speeduino/speeduino.mlg"; - let data = match std::fs::read(file_path) { - Ok(d) => d, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let data = read_example_binary(file_path); assert!(Speeduino::detect(&data), "Should detect as MLG format"); @@ -240,13 +315,7 @@ fn test_speeduino_mlg_parsing() { #[test] fn test_rusefi_mlg_parsing() { let file_path = "exampleLogs/rusefi/rusefilog.mlg"; - let data = match std::fs::read(file_path) { - Ok(d) => d, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let data = read_example_binary(file_path); assert!(Speeduino::detect(&data), "Should detect as MLG format"); @@ -278,6 +347,9 @@ fn test_format_detection_mutual_exclusion() { // ECUMaster uses semicolon-separated CSV starting with "TIME" let ecumaster_data = "TIME;Engine speed\n0.0;1000\n"; + // RomRaider uses comma-separated CSV starting with "Time" + let romraider_data = "Time,RPM,Load\n0,1000,50\n"; + // MLG binary format let mlg_data = b"MLVLG\x00\x00\x01"; @@ -296,6 +368,24 @@ fn test_format_detection_mutual_exclusion() { !EcuMaster::detect(haltech_data), "Haltech should not be detected as ECUMaster" ); + assert!( + !EcuMaster::detect(romraider_data), + "RomRaider should not be detected as ECUMaster" + ); + + // RomRaider detection + assert!( + RomRaider::detect(romraider_data), + "RomRaider format should be detected" + ); + assert!( + !RomRaider::detect(haltech_data), + "Haltech should not be detected as RomRaider" + ); + assert!( + !RomRaider::detect(ecumaster_data), + "ECUMaster should not be detected as RomRaider" + ); // Speeduino/MLG detection assert!(Speeduino::detect(mlg_data), "MLG format should be detected"); @@ -312,13 +402,7 @@ fn test_format_detection_mutual_exclusion() { #[test] fn test_channel_data_extraction() { let file_path = "exampleLogs/haltech/2025-07-18_0215pm_Log1118.csv"; - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let content = read_example_file(file_path); let parser = Haltech; let log = parser.parse(&content).expect("Should parse log"); @@ -346,13 +430,7 @@ fn test_channel_data_extraction() { #[test] fn test_find_channel_index() { let file_path = "exampleLogs/haltech/2025-07-18_0215pm_Log1118.csv"; - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let content = read_example_file(file_path); let parser = Haltech; let log = parser.parse(&content).expect("Should parse log"); @@ -379,13 +457,7 @@ fn test_find_channel_index() { #[test] fn test_time_range_validity() { let file_path = "exampleLogs/speeduino/speeduino.mlg"; - let data = match std::fs::read(file_path) { - Ok(d) => d, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let data = read_example_binary(file_path); let log = Speeduino::parse_binary(&data).expect("Should parse log"); let times = log.get_times_as_f64(); @@ -408,13 +480,7 @@ fn test_time_range_validity() { #[test] fn test_data_values_are_finite() { let file_path = "exampleLogs/haltech/2025-07-18_0215pm_Log1118.csv"; - let content = match std::fs::read_to_string(file_path) { - Ok(c) => c, - Err(_) => { - eprintln!("Skipping test: {} not found", file_path); - return; - } - }; + let content = read_example_file(file_path); let parser = Haltech; let log = parser.parse(&content).expect("Should parse log"); From 83a30ac6b25a7ad7b204e7969575ca8806ea68e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Dec 2025 18:48:39 +0000 Subject: [PATCH 3/3] Fix bare URL warnings in rustdoc comments Wrap URLs in angle brackets to fix rustdoc::bare_urls warnings: - src/parsers/haltech.rs: Haltech CAN protocol reference - src/parsers/romraider.rs: RomRaider GitHub reference --- src/parsers/haltech.rs | 2 +- src/parsers/romraider.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parsers/haltech.rs b/src/parsers/haltech.rs index 83d4d778..596b7d69 100644 --- a/src/parsers/haltech.rs +++ b/src/parsers/haltech.rs @@ -73,7 +73,7 @@ pub enum ChannelType { impl ChannelType { /// Convert raw value to human-readable unit based on Haltech CAN protocol spec - /// Reference: https://support.haltech.com/portal/en/kb/articles/haltech-can-ecu-broadcast-protocol + /// Reference: pub fn convert_value(&self, raw: f64) -> f64 { match self { // RPM: y = x (no conversion) diff --git a/src/parsers/romraider.rs b/src/parsers/romraider.rs index 9cf0d485..436e419f 100644 --- a/src/parsers/romraider.rs +++ b/src/parsers/romraider.rs @@ -4,7 +4,7 @@ //! commonly used for Subaru ECUs. Format is comma-delimited CSV with //! channel names and units in the header row. //! -//! Reference: https://github.com/RomRaider/RomRaider +//! Reference: use serde::Serialize; use std::error::Error;