diff --git a/src-tauri/src/ble/utils.rs b/src-tauri/src/ble/utils.rs index 13ba48f..35ed7a6 100644 --- a/src-tauri/src/ble/utils.rs +++ b/src-tauri/src/ble/utils.rs @@ -7,3 +7,36 @@ pub fn normalize_uuid(u: &str) -> String { } s } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strips_standard_base_suffix() { + assert_eq!(normalize_uuid("00001816-0000-1000-8000-00805f9b34fb"), "1816"); + } + + #[test] + fn test_short_uuid_is_unchanged() { + assert_eq!(normalize_uuid("1816"), "1816"); + } + + #[test] + fn test_trims_whitespace() { + assert_eq!(normalize_uuid(" 1816 "), "1816"); + } + + #[test] + fn test_lowercases_input() { + assert_eq!(normalize_uuid("181D"), "181d"); + } + + #[test] + fn test_custom_128bit_uuid_is_left_untouched() { + assert_eq!( + normalize_uuid("f0001816-0451-4000-b000-000000000000"), + "f0001816-0451-4000-b000-000000000000" + ); + } +} diff --git a/src-tauri/src/device/mod.rs b/src-tauri/src/device/mod.rs index 9277d58..a3e6903 100644 --- a/src-tauri/src/device/mod.rs +++ b/src-tauri/src/device/mod.rs @@ -93,6 +93,23 @@ pub fn get_device_types(device: &Device) -> Vec { mod tests { use super::*; + /// Test helper: builds a Device from service UUIDs and characteristics, + /// with all other fields stubbed. Tests only exercise classification. + fn dev(services: &[&str], characteristics: &[&str]) -> Device { + Device { + id: "test-device".into(), + address: "00:00:00:00:00:00".into(), + display_name: None, + rssi: None, + service_uuids: services.iter().map(|s| s.to_string()).collect(), + characteristics: characteristics.iter().map(|s| s.to_string()).collect(), + manufacturer_data: None, + metadata: None, + } + } + + // Real-device fixture: Magene speed/cadence sensor advertising the CSC + // service (0x1816) as a full 128-bit UUID. #[test] fn test_service_types_for_magene_speedcadence() { let magene = Device { @@ -108,4 +125,101 @@ mod tests { assert_eq!(get_device_types(&magene), [DeviceType::CadSensor]); } + + // Heart rate + #[test] + fn test_hr_sensor_by_service() { + assert_eq!(get_device_types(&dev(&["180d"], &[])), [DeviceType::HrSensor]); + } + + #[test] + fn test_hr_sensor_by_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2a37"])), [DeviceType::HrSensor]); + } + + #[test] + fn test_hr_sensor_by_full_128bit_uuid() { + assert_eq!( + get_device_types(&dev(&["0000180d-0000-1000-8000-00805f9b34fb"], &[])), + [DeviceType::HrSensor] + ); + } + + // Cadence / speed + #[test] + fn test_cadence_by_service() { + assert_eq!(get_device_types(&dev(&["1816"], &[])), [DeviceType::CadSensor]); + } + + #[test] + fn test_cadence_by_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2a5b"])), [DeviceType::CadSensor]); + } + + // Trainer / cycling power / FTMS + #[test] + fn test_trainer_by_ftms_service() { + assert_eq!(get_device_types(&dev(&["1826"], &[])), [DeviceType::Trainer]); + } + + #[test] + fn test_trainer_by_power_service() { + assert_eq!(get_device_types(&dev(&["1818"], &[])), [DeviceType::Trainer]); + } + + #[test] + fn test_trainer_by_power_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2a63"])), [DeviceType::Trainer]); + } + + #[test] + fn test_trainer_by_fitness_machine_characteristic() { + assert_eq!(get_device_types(&dev(&[], &["2acc"])), [DeviceType::Trainer]); + } + + // Indoor bike data (2ADx): a trainer characteristic that also implies cadence. + #[test] + fn test_indoor_bike_data_is_trainer_and_cadence() { + // 2ad2 is both a trainer characteristic and an indoor-bike marker. + assert_eq!( + get_device_types(&dev(&[], &["2ad2"])), + [DeviceType::Trainer, DeviceType::CadSensor] + ); + } + + #[test] + fn test_indoor_bike_variant_is_cadence_only() { + // 2ad5 is an indoor-bike marker but not in the trainer list. + assert_eq!(get_device_types(&dev(&[], &["2ad5"])), [DeviceType::CadSensor]); + } + + // "service::characteristic" strings produced by the scanner. + #[test] + fn test_characteristic_split_on_double_colon() { + assert_eq!(get_device_types(&dev(&[], &["1816::2a5b"])), [DeviceType::CadSensor]); + } + + // Combined and negative cases + #[test] + fn test_hr_and_cadence_combo() { + assert_eq!( + get_device_types(&dev(&["180d", "1816"], &[])), + [DeviceType::HrSensor, DeviceType::CadSensor] + ); + } + + #[test] + fn test_battery_service_is_ignored() { + assert!(get_device_types(&dev(&["180f"], &[])).is_empty()); + } + + #[test] + fn test_empty_device_has_no_types() { + assert!(get_device_types(&dev(&[], &[])).is_empty()); + } + + #[test] + fn test_uuid_case_and_whitespace_are_normalized() { + assert_eq!(get_device_types(&dev(&[" 180D "], &[])), [DeviceType::HrSensor]); + } } diff --git a/src-tauri/src/device_repo/repo.rs b/src-tauri/src/device_repo/repo.rs index 096bd34..94aa018 100644 --- a/src-tauri/src/device_repo/repo.rs +++ b/src-tauri/src/device_repo/repo.rs @@ -60,6 +60,20 @@ pub fn repo_from_devices(devices: Vec) -> Repo { mod tests { use super::*; + /// Helper: a cadence device (CSC service 0x1816) with the given RSSI. + fn cadence_device(rssi: Option) -> Device { + Device { + id: format!("cad-{rssi:?}"), + address: "00:00:00:00:00:00".into(), + display_name: None, + rssi, + service_uuids: vec!["1816".into()], + characteristics: vec![], + manufacturer_data: None, + metadata: None, + } + } + #[test] fn test_repo_classifies_magene_as_cadence_sensor() { let magene = Device { @@ -79,4 +93,42 @@ mod tests { assert!(repo.hr_sensors.is_empty()); assert!(repo.trainers.is_empty()); } + + #[test] + fn test_sorts_devices_by_rssi_descending() { + let devices = vec![ + cadence_device(Some(-80)), + cadence_device(Some(-42)), + cadence_device(Some(-60)), + ]; + let repo = repo_from_devices(devices); + let rssis: Vec<_> = repo.cad_sensors.iter().map(|d| d.rssi).collect(); + assert_eq!(rssis, vec![Some(-42), Some(-60), Some(-80)]); + } + + #[test] + fn test_devices_without_rssi_go_last() { + let devices = vec![cadence_device(None), cadence_device(Some(-50))]; + let repo = repo_from_devices(devices); + assert_eq!(repo.cad_sensors[0].rssi, Some(-50)); + assert_eq!(repo.cad_sensors[1].rssi, None); + } + + #[test] + fn test_indoor_bike_lands_in_trainer_and_cadence_buckets() { + let indoor_bike = Device { + id: "indoor-bike".into(), + address: "00:00:00:00:00:00".into(), + display_name: None, + rssi: Some(-70), + service_uuids: vec![], + characteristics: vec!["2ad2".into()], + manufacturer_data: None, + metadata: None, + }; + let repo = repo_from_devices(vec![indoor_bike]); + assert_eq!(repo.trainers.len(), 1); + assert_eq!(repo.cad_sensors.len(), 1); + assert!(repo.hr_sensors.is_empty()); + } }