aranet_types/
lib.rs

1#![deny(unsafe_code)]
2
3//! Platform-agnostic types for Aranet environmental sensors.
4//!
5//! This crate provides shared types used across all aranet crates
6//! including aranet-core, aranet-cli, aranet-tui, and aranet-gui.
7//!
8//! # Features
9//!
10//! - Core data types for sensor readings
11//! - Device information structures
12//! - UUID constants for BLE characteristics
13//! - Error types for data parsing
14//!
15//! # Example
16//!
17//! ```
18//! use aranet_types::{CurrentReading, Status, DeviceType};
19//!
20//! // Types can be used for parsing and serialization
21//! ```
22
23pub mod error;
24pub mod types;
25pub mod uuid;
26
27pub use error::{ParseError, ParseResult};
28pub use types::{
29    CurrentReading, CurrentReadingBuilder, DeviceInfo, DeviceInfoBuilder, DeviceType,
30    HistoryRecord, HistoryRecordBuilder, MIN_CURRENT_READING_BYTES, Status,
31};
32
33// Re-export uuid module with a clearer name to avoid confusion with the `uuid` crate.
34// The `uuids` alias is kept for backwards compatibility.
35pub use uuid as ble;
36#[doc(hidden)]
37pub use uuid as uuids;
38
39/// Unit tests for aranet-types.
40///
41/// # Test Coverage
42///
43/// This module provides comprehensive tests for all public types and parsing functions:
44///
45/// ## CurrentReading Tests
46/// - Parsing from valid 13-byte Aranet4 format
47/// - Parsing from valid 7-byte Aranet2 format
48/// - Error handling for insufficient bytes
49/// - Edge cases (all zeros, max values)
50/// - Builder pattern validation
51/// - Serialization/deserialization roundtrips
52///
53/// ## Status Enum Tests
54/// - Conversion from u8 values (0-3 and unknown)
55/// - Display and Debug formatting
56/// - Equality and ordering
57///
58/// ## DeviceType Tests
59/// - Conversion from u8 device codes (0xF1-0xF4)
60/// - Name-based detection from device names
61/// - Display formatting
62/// - Hash implementation for use in collections
63///
64/// ## DeviceInfo Tests
65/// - Clone and Debug implementations
66/// - Default values
67/// - Equality comparisons
68///
69/// ## HistoryRecord Tests
70/// - Clone and equality
71/// - Timestamp handling
72///
73/// ## ParseError Tests
74/// - Error message formatting
75/// - Equality comparisons
76/// - Helper constructors
77///
78/// ## BLE UUID Tests
79/// - Service UUID constants
80/// - Characteristic UUID constants
81///
82/// # Running Tests
83///
84/// ```bash
85/// cargo test -p aranet-types
86/// ```
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    // ========================================================================
92    // CurrentReading parsing tests
93    // ========================================================================
94
95    #[test]
96    fn test_parse_current_reading_from_valid_bytes() {
97        // Construct test bytes:
98        // CO2: 800 (0x0320 LE -> [0x20, 0x03])
99        // Temperature: 450 raw (22.5°C = 450/20) -> [0xC2, 0x01]
100        // Pressure: 10132 raw (1013.2 hPa = 10132/10) -> [0x94, 0x27]
101        // Humidity: 45
102        // Battery: 85
103        // Status: 1 (Green)
104        // Interval: 300 -> [0x2C, 0x01]
105        // Age: 120 -> [0x78, 0x00]
106        let bytes: [u8; 13] = [
107            0x20, 0x03, // CO2 = 800
108            0xC2, 0x01, // temp_raw = 450
109            0x94, 0x27, // pressure_raw = 10132
110            45,   // humidity
111            85,   // battery
112            1,    // status = Green
113            0x2C, 0x01, // interval = 300
114            0x78, 0x00, // age = 120
115        ];
116
117        let reading = CurrentReading::from_bytes(&bytes).unwrap();
118
119        assert_eq!(reading.co2, 800);
120        assert!((reading.temperature - 22.5).abs() < 0.01);
121        assert!((reading.pressure - 1013.2).abs() < 0.1);
122        assert_eq!(reading.humidity, 45);
123        assert_eq!(reading.battery, 85);
124        assert_eq!(reading.status, Status::Green);
125        assert_eq!(reading.interval, 300);
126        assert_eq!(reading.age, 120);
127    }
128
129    #[test]
130    fn test_parse_current_reading_from_insufficient_bytes() {
131        let bytes: [u8; 10] = [0; 10]; // Only 10 bytes, need 13
132
133        let result = CurrentReading::from_bytes(&bytes);
134
135        assert!(result.is_err());
136        let err = result.unwrap_err();
137        assert_eq!(
138            err,
139            ParseError::InsufficientBytes {
140                expected: 13,
141                actual: 10
142            }
143        );
144        assert!(err.to_string().contains("expected 13"));
145        assert!(err.to_string().contains("got 10"));
146    }
147
148    #[test]
149    fn test_parse_current_reading_zero_bytes() {
150        let bytes: [u8; 0] = [];
151
152        let result = CurrentReading::from_bytes(&bytes);
153        assert!(result.is_err());
154    }
155
156    #[test]
157    fn test_parse_current_reading_all_zeros() {
158        let bytes: [u8; 13] = [0; 13];
159
160        let reading = CurrentReading::from_bytes(&bytes).unwrap();
161        assert_eq!(reading.co2, 0);
162        assert!((reading.temperature - 0.0).abs() < 0.01);
163        assert!((reading.pressure - 0.0).abs() < 0.1);
164        assert_eq!(reading.humidity, 0);
165        assert_eq!(reading.battery, 0);
166        assert_eq!(reading.status, Status::Error);
167        assert_eq!(reading.interval, 0);
168        assert_eq!(reading.age, 0);
169    }
170
171    #[test]
172    fn test_parse_current_reading_max_values() {
173        let bytes: [u8; 13] = [
174            0xFF, 0xFF, // CO2 = 65535
175            0xFF, 0xFF, // temp_raw = -1 as i16 (-0.05°C signed)
176            0xFF, 0xFF, // pressure_raw = 65535
177            0xFF, // humidity = 255
178            0xFF, // battery = 255
179            3,    // status = Red
180            0xFF, 0xFF, // interval = 65535
181            0xFF, 0xFF, // age = 65535
182        ];
183
184        let reading = CurrentReading::from_bytes(&bytes).unwrap();
185        assert_eq!(reading.co2, 65535);
186        assert!((reading.temperature - (-0.05)).abs() < 0.01); // -1 as i16 / 20
187        assert!((reading.pressure - 6553.5).abs() < 0.1); // 65535/10
188        assert_eq!(reading.humidity, 255);
189        assert_eq!(reading.battery, 255);
190        assert_eq!(reading.interval, 65535);
191        assert_eq!(reading.age, 65535);
192    }
193
194    #[test]
195    fn test_parse_current_reading_high_co2_red_status() {
196        // 2000 ppm CO2 = Red status
197        let bytes: [u8; 13] = [
198            0xD0, 0x07, // CO2 = 2000
199            0xC2, 0x01, // temp
200            0x94, 0x27, // pressure
201            50, 80, 3, // Red status
202            0x2C, 0x01, 0x78, 0x00,
203        ];
204
205        let reading = CurrentReading::from_bytes(&bytes).unwrap();
206        assert_eq!(reading.co2, 2000);
207        assert_eq!(reading.status, Status::Red);
208    }
209
210    #[test]
211    fn test_parse_current_reading_moderate_co2_yellow_status() {
212        // 1200 ppm CO2 = Yellow status
213        let bytes: [u8; 13] = [
214            0xB0, 0x04, // CO2 = 1200
215            0xC2, 0x01, 0x94, 0x27, 50, 80, 2, // Yellow status
216            0x2C, 0x01, 0x78, 0x00,
217        ];
218
219        let reading = CurrentReading::from_bytes(&bytes).unwrap();
220        assert_eq!(reading.co2, 1200);
221        assert_eq!(reading.status, Status::Yellow);
222    }
223
224    #[test]
225    fn test_parse_current_reading_extra_bytes_ignored() {
226        // More than 13 bytes should work (extra bytes ignored)
227        let bytes: [u8; 16] = [
228            0x20, 0x03, 0xC2, 0x01, 0x94, 0x27, 45, 85, 1, 0x2C, 0x01, 0x78, 0x00, 0xAA, 0xBB, 0xCC,
229        ];
230
231        let reading = CurrentReading::from_bytes(&bytes).unwrap();
232        assert_eq!(reading.co2, 800);
233    }
234
235    // --- Status enum tests ---
236
237    #[test]
238    fn test_status_from_u8() {
239        assert_eq!(Status::from(0), Status::Error);
240        assert_eq!(Status::from(1), Status::Green);
241        assert_eq!(Status::from(2), Status::Yellow);
242        assert_eq!(Status::from(3), Status::Red);
243        // Unknown values should map to Error
244        assert_eq!(Status::from(4), Status::Error);
245        assert_eq!(Status::from(255), Status::Error);
246    }
247
248    #[test]
249    fn test_status_repr_values() {
250        assert_eq!(Status::Error as u8, 0);
251        assert_eq!(Status::Green as u8, 1);
252        assert_eq!(Status::Yellow as u8, 2);
253        assert_eq!(Status::Red as u8, 3);
254    }
255
256    #[test]
257    fn test_status_debug() {
258        assert_eq!(format!("{:?}", Status::Green), "Green");
259        assert_eq!(format!("{:?}", Status::Yellow), "Yellow");
260        assert_eq!(format!("{:?}", Status::Red), "Red");
261        assert_eq!(format!("{:?}", Status::Error), "Error");
262    }
263
264    #[test]
265    fn test_status_clone() {
266        let status = Status::Green;
267        // Status implements Copy, so we can just copy it
268        let cloned = status;
269        assert_eq!(status, cloned);
270    }
271
272    #[test]
273    fn test_status_copy() {
274        let status = Status::Red;
275        let copied = status; // Copy
276        assert_eq!(status, copied); // Original still valid
277    }
278
279    // --- DeviceType enum tests ---
280
281    #[test]
282    fn test_device_type_values() {
283        assert_eq!(DeviceType::Aranet4 as u8, 0xF1);
284        assert_eq!(DeviceType::Aranet2 as u8, 0xF2);
285        assert_eq!(DeviceType::AranetRadon as u8, 0xF3);
286        assert_eq!(DeviceType::AranetRadiation as u8, 0xF4);
287    }
288
289    #[test]
290    fn test_device_type_debug() {
291        assert_eq!(format!("{:?}", DeviceType::Aranet4), "Aranet4");
292        assert_eq!(format!("{:?}", DeviceType::Aranet2), "Aranet2");
293        assert_eq!(format!("{:?}", DeviceType::AranetRadon), "AranetRadon");
294        assert_eq!(
295            format!("{:?}", DeviceType::AranetRadiation),
296            "AranetRadiation"
297        );
298    }
299
300    #[test]
301    fn test_device_type_clone() {
302        let device_type = DeviceType::Aranet4;
303        // DeviceType implements Copy, so we can just copy it
304        let cloned = device_type;
305        assert_eq!(device_type, cloned);
306    }
307
308    #[test]
309    fn test_device_type_try_from_u8() {
310        assert_eq!(DeviceType::try_from(0xF1), Ok(DeviceType::Aranet4));
311        assert_eq!(DeviceType::try_from(0xF2), Ok(DeviceType::Aranet2));
312        assert_eq!(DeviceType::try_from(0xF3), Ok(DeviceType::AranetRadon));
313        assert_eq!(DeviceType::try_from(0xF4), Ok(DeviceType::AranetRadiation));
314    }
315
316    #[test]
317    fn test_device_type_try_from_u8_invalid() {
318        let result = DeviceType::try_from(0x00);
319        assert!(result.is_err());
320        assert_eq!(result.unwrap_err(), ParseError::UnknownDeviceType(0x00));
321
322        let result = DeviceType::try_from(0xFF);
323        assert!(result.is_err());
324        assert_eq!(result.unwrap_err(), ParseError::UnknownDeviceType(0xFF));
325    }
326
327    #[test]
328    fn test_device_type_display() {
329        assert_eq!(format!("{}", DeviceType::Aranet4), "Aranet4");
330        assert_eq!(format!("{}", DeviceType::Aranet2), "Aranet2");
331        assert_eq!(format!("{}", DeviceType::AranetRadon), "Aranet Radon");
332        assert_eq!(
333            format!("{}", DeviceType::AranetRadiation),
334            "Aranet Radiation"
335        );
336    }
337
338    #[test]
339    fn test_device_type_hash() {
340        use std::collections::HashSet;
341        let mut set = HashSet::new();
342        set.insert(DeviceType::Aranet4);
343        set.insert(DeviceType::Aranet2);
344        set.insert(DeviceType::Aranet4); // duplicate
345        assert_eq!(set.len(), 2);
346        assert!(set.contains(&DeviceType::Aranet4));
347        assert!(set.contains(&DeviceType::Aranet2));
348    }
349
350    #[test]
351    fn test_status_display() {
352        assert_eq!(format!("{}", Status::Error), "Error");
353        assert_eq!(format!("{}", Status::Green), "Good");
354        assert_eq!(format!("{}", Status::Yellow), "Moderate");
355        assert_eq!(format!("{}", Status::Red), "High");
356    }
357
358    #[test]
359    fn test_status_hash() {
360        use std::collections::HashSet;
361        let mut set = HashSet::new();
362        set.insert(Status::Green);
363        set.insert(Status::Yellow);
364        set.insert(Status::Green); // duplicate
365        assert_eq!(set.len(), 2);
366        assert!(set.contains(&Status::Green));
367        assert!(set.contains(&Status::Yellow));
368    }
369
370    // --- DeviceInfo tests ---
371
372    #[test]
373    fn test_device_info_creation() {
374        let info = types::DeviceInfo {
375            name: "Aranet4 12345".to_string(),
376            model: "Aranet4".to_string(),
377            serial: "12345".to_string(),
378            firmware: "v1.2.0".to_string(),
379            hardware: "1.0".to_string(),
380            software: "1.2.0".to_string(),
381            manufacturer: "SAF Tehnika".to_string(),
382        };
383
384        assert_eq!(info.name, "Aranet4 12345");
385        assert_eq!(info.serial, "12345");
386        assert_eq!(info.manufacturer, "SAF Tehnika");
387    }
388
389    #[test]
390    fn test_device_info_clone() {
391        let info = types::DeviceInfo {
392            name: "Test".to_string(),
393            model: "Model".to_string(),
394            serial: "123".to_string(),
395            firmware: "1.0".to_string(),
396            hardware: "1.0".to_string(),
397            software: "1.0".to_string(),
398            manufacturer: "Mfg".to_string(),
399        };
400
401        let cloned = info.clone();
402        assert_eq!(cloned.name, info.name);
403        assert_eq!(cloned.serial, info.serial);
404    }
405
406    #[test]
407    fn test_device_info_debug() {
408        let info = types::DeviceInfo {
409            name: "Aranet4".to_string(),
410            model: "".to_string(),
411            serial: "".to_string(),
412            firmware: "".to_string(),
413            hardware: "".to_string(),
414            software: "".to_string(),
415            manufacturer: "".to_string(),
416        };
417
418        let debug_str = format!("{:?}", info);
419        assert!(debug_str.contains("Aranet4"));
420    }
421
422    #[test]
423    fn test_device_info_default() {
424        let info = types::DeviceInfo::default();
425        assert_eq!(info.name, "");
426        assert_eq!(info.model, "");
427        assert_eq!(info.serial, "");
428        assert_eq!(info.firmware, "");
429        assert_eq!(info.hardware, "");
430        assert_eq!(info.software, "");
431        assert_eq!(info.manufacturer, "");
432    }
433
434    #[test]
435    fn test_device_info_equality() {
436        let info1 = types::DeviceInfo {
437            name: "Test".to_string(),
438            model: "Model".to_string(),
439            serial: "123".to_string(),
440            firmware: "1.0".to_string(),
441            hardware: "1.0".to_string(),
442            software: "1.0".to_string(),
443            manufacturer: "Mfg".to_string(),
444        };
445        let info2 = info1.clone();
446        let info3 = types::DeviceInfo {
447            name: "Different".to_string(),
448            ..info1.clone()
449        };
450        assert_eq!(info1, info2);
451        assert_ne!(info1, info3);
452    }
453
454    // --- HistoryRecord tests ---
455
456    #[test]
457    fn test_history_record_creation() {
458        use time::OffsetDateTime;
459
460        let record = types::HistoryRecord {
461            timestamp: OffsetDateTime::UNIX_EPOCH,
462            co2: 800,
463            temperature: 22.5,
464            pressure: 1013.2,
465            humidity: 45,
466            radon: None,
467            radiation_rate: None,
468            radiation_total: None,
469        };
470
471        assert_eq!(record.co2, 800);
472        assert!((record.temperature - 22.5).abs() < 0.01);
473        assert!((record.pressure - 1013.2).abs() < 0.1);
474        assert_eq!(record.humidity, 45);
475        assert!(record.radon.is_none());
476        assert!(record.radiation_rate.is_none());
477        assert!(record.radiation_total.is_none());
478    }
479
480    #[test]
481    fn test_history_record_clone() {
482        use time::OffsetDateTime;
483
484        let record = types::HistoryRecord {
485            timestamp: OffsetDateTime::UNIX_EPOCH,
486            co2: 500,
487            temperature: 20.0,
488            pressure: 1000.0,
489            humidity: 50,
490            radon: Some(100),
491            radiation_rate: Some(0.15),
492            radiation_total: Some(1.5),
493        };
494
495        let cloned = record.clone();
496        assert_eq!(cloned.co2, record.co2);
497        assert_eq!(cloned.humidity, record.humidity);
498        assert_eq!(cloned.radon, Some(100));
499        assert_eq!(cloned.radiation_rate, Some(0.15));
500        assert_eq!(cloned.radiation_total, Some(1.5));
501    }
502
503    #[test]
504    fn test_history_record_equality() {
505        use time::OffsetDateTime;
506
507        let record1 = types::HistoryRecord {
508            timestamp: OffsetDateTime::UNIX_EPOCH,
509            co2: 800,
510            temperature: 22.5,
511            pressure: 1013.2,
512            humidity: 45,
513            radon: None,
514            radiation_rate: None,
515            radiation_total: None,
516        };
517        let record2 = record1.clone();
518        assert_eq!(record1, record2);
519    }
520
521    #[test]
522    fn test_current_reading_equality() {
523        let reading1 = CurrentReading {
524            co2: 800,
525            temperature: 22.5,
526            pressure: 1013.2,
527            humidity: 45,
528            battery: 85,
529            status: Status::Green,
530            interval: 300,
531            age: 120,
532            captured_at: None,
533            radon: None,
534            radiation_rate: None,
535            radiation_total: None,
536            radon_avg_24h: None,
537            radon_avg_7d: None,
538            radon_avg_30d: None,
539        };
540        // CurrentReading implements Copy, so we can just copy it
541        let reading2 = reading1;
542        assert_eq!(reading1, reading2);
543    }
544
545    #[test]
546    fn test_min_current_reading_bytes_const() {
547        assert_eq!(MIN_CURRENT_READING_BYTES, 13);
548        // Ensure buffer of exact size works
549        let bytes = [0u8; MIN_CURRENT_READING_BYTES];
550        assert!(CurrentReading::from_bytes(&bytes).is_ok());
551        // Ensure buffer one byte short fails
552        let short_bytes = [0u8; MIN_CURRENT_READING_BYTES - 1];
553        assert!(CurrentReading::from_bytes(&short_bytes).is_err());
554    }
555
556    // --- ParseError tests ---
557
558    #[test]
559    fn test_parse_error_display() {
560        let err = ParseError::invalid_value("test message");
561        assert_eq!(err.to_string(), "Invalid value: test message");
562    }
563
564    #[test]
565    fn test_parse_error_insufficient_bytes() {
566        let err = ParseError::InsufficientBytes {
567            expected: 13,
568            actual: 5,
569        };
570        assert_eq!(err.to_string(), "Insufficient bytes: expected 13, got 5");
571    }
572
573    #[test]
574    fn test_parse_error_unknown_device_type() {
575        let err = ParseError::UnknownDeviceType(0xAB);
576        assert_eq!(err.to_string(), "Unknown device type: 0xAB");
577    }
578
579    #[test]
580    fn test_parse_error_invalid_value() {
581        let err = ParseError::InvalidValue("bad value".to_string());
582        assert_eq!(err.to_string(), "Invalid value: bad value");
583    }
584
585    #[test]
586    fn test_parse_error_debug() {
587        let err = ParseError::invalid_value("debug test");
588        let debug_str = format!("{:?}", err);
589        assert!(debug_str.contains("InvalidValue"));
590        assert!(debug_str.contains("debug test"));
591    }
592
593    #[test]
594    fn test_parse_error_equality() {
595        let err1 = ParseError::InsufficientBytes {
596            expected: 10,
597            actual: 5,
598        };
599        let err2 = ParseError::InsufficientBytes {
600            expected: 10,
601            actual: 5,
602        };
603        let err3 = ParseError::InsufficientBytes {
604            expected: 10,
605            actual: 6,
606        };
607        assert_eq!(err1, err2);
608        assert_ne!(err1, err3);
609    }
610
611    // --- Serialization tests ---
612
613    #[cfg(feature = "serde")]
614    #[test]
615    fn test_current_reading_serialization() {
616        let reading = CurrentReading {
617            co2: 800,
618            temperature: 22.5,
619            pressure: 1013.2,
620            humidity: 45,
621            battery: 85,
622            status: Status::Green,
623            interval: 300,
624            age: 120,
625            captured_at: None,
626            radon: None,
627            radiation_rate: None,
628            radiation_total: None,
629            radon_avg_24h: None,
630            radon_avg_7d: None,
631            radon_avg_30d: None,
632        };
633
634        let json = serde_json::to_string(&reading).unwrap();
635        assert!(json.contains("\"co2\":800"));
636        assert!(json.contains("\"humidity\":45"));
637    }
638
639    #[cfg(feature = "serde")]
640    #[test]
641    fn test_current_reading_deserialization() {
642        let json = r#"{"co2":800,"temperature":22.5,"pressure":1013.2,"humidity":45,"battery":85,"status":"Green","interval":300,"age":120,"radon":null,"radiation_rate":null,"radiation_total":null}"#;
643
644        let reading: CurrentReading = serde_json::from_str(json).unwrap();
645        assert_eq!(reading.co2, 800);
646        assert_eq!(reading.status, Status::Green);
647    }
648
649    #[cfg(feature = "serde")]
650    #[test]
651    fn test_status_serialization() {
652        assert_eq!(serde_json::to_string(&Status::Green).unwrap(), "\"Green\"");
653        assert_eq!(
654            serde_json::to_string(&Status::Yellow).unwrap(),
655            "\"Yellow\""
656        );
657        assert_eq!(serde_json::to_string(&Status::Red).unwrap(), "\"Red\"");
658        assert_eq!(serde_json::to_string(&Status::Error).unwrap(), "\"Error\"");
659    }
660
661    #[cfg(feature = "serde")]
662    #[test]
663    fn test_device_type_serialization() {
664        assert_eq!(
665            serde_json::to_string(&DeviceType::Aranet4).unwrap(),
666            "\"Aranet4\""
667        );
668        assert_eq!(
669            serde_json::to_string(&DeviceType::AranetRadon).unwrap(),
670            "\"AranetRadon\""
671        );
672    }
673
674    #[cfg(feature = "serde")]
675    #[test]
676    fn test_device_info_serialization_roundtrip() {
677        let info = types::DeviceInfo {
678            name: "Test Device".to_string(),
679            model: "Model X".to_string(),
680            serial: "SN12345".to_string(),
681            firmware: "1.2.3".to_string(),
682            hardware: "2.0".to_string(),
683            software: "3.0".to_string(),
684            manufacturer: "Acme Corp".to_string(),
685        };
686
687        let json = serde_json::to_string(&info).unwrap();
688        let deserialized: types::DeviceInfo = serde_json::from_str(&json).unwrap();
689
690        assert_eq!(deserialized.name, info.name);
691        assert_eq!(deserialized.serial, info.serial);
692        assert_eq!(deserialized.manufacturer, info.manufacturer);
693    }
694
695    // --- New feature tests ---
696
697    #[test]
698    fn test_status_ordering() {
699        // Status should be ordered by severity
700        assert!(Status::Error < Status::Green);
701        assert!(Status::Green < Status::Yellow);
702        assert!(Status::Yellow < Status::Red);
703
704        // Test comparison operators
705        assert!(Status::Red > Status::Yellow);
706        assert!(Status::Yellow >= Status::Yellow);
707        assert!(Status::Green <= Status::Yellow);
708    }
709
710    #[test]
711    fn test_device_type_readings_characteristic() {
712        use crate::ble;
713
714        // Aranet4 uses the original characteristic
715        assert_eq!(
716            DeviceType::Aranet4.readings_characteristic(),
717            ble::CURRENT_READINGS_DETAIL
718        );
719
720        // Other devices use the alternate characteristic
721        assert_eq!(
722            DeviceType::Aranet2.readings_characteristic(),
723            ble::CURRENT_READINGS_DETAIL_ALT
724        );
725        assert_eq!(
726            DeviceType::AranetRadon.readings_characteristic(),
727            ble::CURRENT_READINGS_DETAIL_ALT
728        );
729        assert_eq!(
730            DeviceType::AranetRadiation.readings_characteristic(),
731            ble::CURRENT_READINGS_DETAIL_ALT
732        );
733    }
734
735    #[test]
736    fn test_device_type_from_name_word_boundary() {
737        // Should match at word boundaries
738        assert_eq!(
739            DeviceType::from_name("Aranet4 12345"),
740            Some(DeviceType::Aranet4)
741        );
742        assert_eq!(
743            DeviceType::from_name("My Aranet4"),
744            Some(DeviceType::Aranet4)
745        );
746
747        // Should match case-insensitively
748        assert_eq!(DeviceType::from_name("ARANET4"), Some(DeviceType::Aranet4));
749        assert_eq!(DeviceType::from_name("aranet2"), Some(DeviceType::Aranet2));
750
751        // Should match AranetRn+ naming convention (real device name format)
752        assert_eq!(
753            DeviceType::from_name("AranetRn+ 306B8"),
754            Some(DeviceType::AranetRadon)
755        );
756        assert_eq!(
757            DeviceType::from_name("aranetrn+ 12345"),
758            Some(DeviceType::AranetRadon)
759        );
760
761        // Should match Aranet Radiation by ☢ symbol (real device name format)
762        assert_eq!(
763            DeviceType::from_name("Aranet\u{2622} 30ED1"),
764            Some(DeviceType::AranetRadiation)
765        );
766        assert_eq!(
767            DeviceType::from_name("Aranet Radiation"),
768            Some(DeviceType::AranetRadiation)
769        );
770    }
771
772    #[test]
773    fn test_device_type_has_co2() {
774        assert!(DeviceType::Aranet4.has_co2());
775        assert!(!DeviceType::Aranet2.has_co2());
776        assert!(!DeviceType::AranetRadon.has_co2());
777        assert!(!DeviceType::AranetRadiation.has_co2());
778    }
779
780    #[test]
781    fn test_device_type_has_temperature() {
782        assert!(DeviceType::Aranet4.has_temperature());
783        assert!(DeviceType::Aranet2.has_temperature());
784        assert!(DeviceType::AranetRadon.has_temperature());
785        assert!(!DeviceType::AranetRadiation.has_temperature());
786    }
787
788    #[test]
789    fn test_device_type_has_humidity() {
790        assert!(DeviceType::Aranet4.has_humidity());
791        assert!(DeviceType::Aranet2.has_humidity());
792        assert!(DeviceType::AranetRadon.has_humidity());
793        assert!(!DeviceType::AranetRadiation.has_humidity());
794    }
795
796    #[test]
797    fn test_device_type_has_pressure() {
798        assert!(DeviceType::Aranet4.has_pressure());
799        assert!(!DeviceType::Aranet2.has_pressure());
800        assert!(DeviceType::AranetRadon.has_pressure());
801        assert!(!DeviceType::AranetRadiation.has_pressure());
802    }
803
804    #[test]
805    fn test_byte_size_constants() {
806        assert_eq!(MIN_CURRENT_READING_BYTES, 13);
807        assert_eq!(types::MIN_ARANET2_READING_BYTES, 12);
808        assert_eq!(types::MIN_RADON_READING_BYTES, 15);
809        assert_eq!(types::MIN_RADON_GATT_READING_BYTES, 18);
810        assert_eq!(types::MIN_RADIATION_READING_BYTES, 28);
811    }
812
813    #[test]
814    fn test_from_bytes_aranet2() {
815        // 12 bytes GATT format: header(2), interval(2), age(2), battery(1), temp(2), humidity(2), status_flags(1)
816        let data = [
817            0x02, 0x00, // header
818            0x2C, 0x01, // interval = 300
819            0x3C, 0x00, // age = 60
820            0x55, // battery = 85
821            0x90, 0x01, // temp = 400 -> 20.0°C
822            0xF4, 0x01, // humidity = 500 -> 50%
823            0x04, // status flags: bits[2:3] = 01 = Green
824        ];
825
826        let reading = CurrentReading::from_bytes_aranet2(&data).unwrap();
827        assert_eq!(reading.co2, 0); // Aranet2 has no CO2
828        assert!((reading.temperature - 20.0).abs() < 0.1);
829        assert_eq!(reading.humidity, 50);
830        assert_eq!(reading.battery, 85);
831        assert_eq!(reading.status, Status::Green);
832        assert_eq!(reading.interval, 300);
833        assert_eq!(reading.age, 60);
834        assert_eq!(reading.pressure, 0.0); // Aranet2 has no pressure
835    }
836
837    #[test]
838    fn test_from_bytes_aranet2_insufficient() {
839        let data = [0u8; 11]; // Too short (need 12)
840        let result = CurrentReading::from_bytes_aranet2(&data);
841        assert!(result.is_err());
842    }
843
844    #[test]
845    fn test_from_bytes_for_device() {
846        // Test dispatch to correct parser
847        let aranet4_data = [0u8; 13];
848        let result = CurrentReading::from_bytes_for_device(&aranet4_data, DeviceType::Aranet4);
849        assert!(result.is_ok());
850
851        let aranet2_data = [0u8; 12];
852        let result = CurrentReading::from_bytes_for_device(&aranet2_data, DeviceType::Aranet2);
853        assert!(result.is_ok());
854    }
855
856    #[test]
857    fn test_builder_with_captured_at() {
858        use time::OffsetDateTime;
859
860        let now = OffsetDateTime::now_utc();
861        let reading = CurrentReading::builder()
862            .co2(800)
863            .temperature(22.5)
864            .captured_at(now)
865            .build();
866
867        assert_eq!(reading.co2, 800);
868        assert_eq!(reading.captured_at, Some(now));
869    }
870
871    #[test]
872    fn test_builder_try_build_valid() {
873        let result = CurrentReading::builder()
874            .co2(800)
875            .temperature(22.5)
876            .pressure(1013.0)
877            .humidity(50)
878            .battery(85)
879            .try_build();
880
881        assert!(result.is_ok());
882    }
883
884    #[test]
885    fn test_builder_try_build_invalid_humidity() {
886        let result = CurrentReading::builder()
887            .humidity(150) // Invalid: > 100
888            .try_build();
889
890        assert!(result.is_err());
891        let err = result.unwrap_err();
892        assert!(err.to_string().contains("humidity"));
893    }
894
895    #[test]
896    fn test_builder_try_build_invalid_battery() {
897        let result = CurrentReading::builder()
898            .battery(120) // Invalid: > 100
899            .try_build();
900
901        assert!(result.is_err());
902        let err = result.unwrap_err();
903        assert!(err.to_string().contains("battery"));
904    }
905
906    #[test]
907    fn test_builder_try_build_invalid_temperature() {
908        let result = CurrentReading::builder()
909            .temperature(-50.0) // Invalid: < -40
910            .try_build();
911
912        assert!(result.is_err());
913        let err = result.unwrap_err();
914        assert!(err.to_string().contains("temperature"));
915    }
916
917    #[test]
918    fn test_builder_try_build_invalid_pressure() {
919        let result = CurrentReading::builder()
920            .temperature(22.0) // Valid temperature
921            .pressure(500.0) // Invalid: < 800
922            .try_build();
923
924        assert!(result.is_err());
925        let err = result.unwrap_err();
926        assert!(err.to_string().contains("pressure"));
927    }
928
929    #[test]
930    fn test_with_captured_at() {
931        use time::OffsetDateTime;
932
933        let reading = CurrentReading::builder().age(60).build();
934
935        let now = OffsetDateTime::now_utc();
936        let reading_with_time = reading.with_captured_at(now);
937
938        assert!(reading_with_time.captured_at.is_some());
939        // The captured_at should be approximately now - 60 seconds
940        let captured = reading_with_time.captured_at.unwrap();
941        let expected = now - time::Duration::seconds(60);
942        assert!((captured - expected).whole_seconds().abs() < 2);
943    }
944
945    #[test]
946    fn test_parse_error_invalid_value_helper() {
947        let err = ParseError::invalid_value("test error");
948        assert_eq!(err.to_string(), "Invalid value: test error");
949    }
950}
951
952/// Property-based tests using proptest.
953///
954/// These tests use randomized inputs to verify that parsing functions:
955/// 1. Never panic on any input (safety guarantee)
956/// 2. Correctly parse valid inputs (correctness guarantee)
957/// 3. Properly roundtrip through serialization (consistency guarantee)
958///
959/// # Test Categories
960///
961/// ## Panic Safety Tests
962/// - `parse_current_reading_never_panics`: Random bytes to `from_bytes`
963/// - `parse_aranet2_never_panics`: Random bytes to `from_bytes_aranet2`
964/// - `status_from_u8_never_panics`: Any u8 to Status
965/// - `device_type_try_from_never_panics`: Any u8 to DeviceType
966///
967/// ## Valid Input Tests
968/// - `parse_valid_aranet4_bytes`: Structured valid Aranet4 data
969/// - `parse_valid_aranet2_bytes`: Structured valid Aranet2 data
970///
971/// ## Roundtrip Tests
972/// - `current_reading_json_roundtrip`: JSON serialization consistency
973///
974/// # Running Property Tests
975///
976/// ```bash
977/// cargo test -p aranet-types proptests
978/// ```
979///
980/// To run with more test cases:
981/// ```bash
982/// PROPTEST_CASES=10000 cargo test -p aranet-types proptests
983/// ```
984#[cfg(test)]
985mod proptests {
986    use super::*;
987    use proptest::prelude::*;
988
989    proptest! {
990        /// Parsing random bytes should never panic - it may return Ok or Err,
991        /// but should always be safe to call.
992        #[test]
993        fn parse_current_reading_never_panics(data: Vec<u8>) {
994            let _ = CurrentReading::from_bytes(&data);
995        }
996
997        /// Parsing random bytes for Aranet2 should never panic.
998        #[test]
999        fn parse_aranet2_never_panics(data: Vec<u8>) {
1000            let _ = CurrentReading::from_bytes_aranet2(&data);
1001        }
1002
1003        /// Status conversion from any u8 should never panic.
1004        #[test]
1005        fn status_from_u8_never_panics(value: u8) {
1006            let status = Status::from(value);
1007            // Should always produce a valid Status variant
1008            let _ = format!("{:?}", status);
1009        }
1010
1011        /// DeviceType conversion should return Ok or Err, never panic.
1012        #[test]
1013        fn device_type_try_from_never_panics(value: u8) {
1014            let _ = DeviceType::try_from(value);
1015        }
1016
1017        /// Valid 13-byte input should always parse successfully for Aranet4.
1018        #[test]
1019        fn parse_valid_aranet4_bytes(
1020            co2 in 0u16..10000u16,
1021            temp_raw in 0u16..2000u16,
1022            pressure_raw in 8000u16..12000u16,
1023            humidity in 0u8..100u8,
1024            battery in 0u8..100u8,
1025            status_byte in 0u8..4u8,
1026            interval in 60u16..3600u16,
1027            age in 0u16..3600u16,
1028        ) {
1029            let mut data = [0u8; 13];
1030            data[0..2].copy_from_slice(&co2.to_le_bytes());
1031            data[2..4].copy_from_slice(&temp_raw.to_le_bytes());
1032            data[4..6].copy_from_slice(&pressure_raw.to_le_bytes());
1033            data[6] = humidity;
1034            data[7] = battery;
1035            data[8] = status_byte;
1036            data[9..11].copy_from_slice(&interval.to_le_bytes());
1037            data[11..13].copy_from_slice(&age.to_le_bytes());
1038
1039            let result = CurrentReading::from_bytes(&data);
1040            prop_assert!(result.is_ok());
1041
1042            let reading = result.unwrap();
1043            prop_assert_eq!(reading.co2, co2);
1044            prop_assert_eq!(reading.humidity, humidity);
1045            prop_assert_eq!(reading.battery, battery);
1046            prop_assert_eq!(reading.interval, interval);
1047            prop_assert_eq!(reading.age, age);
1048        }
1049
1050        /// Valid 12-byte GATT input should always parse successfully for Aranet2.
1051        #[test]
1052        fn parse_valid_aranet2_bytes(
1053            temp_raw in 0u16..2000u16,
1054            humidity_raw in 0u16..1000u16,
1055            battery in 0u8..100u8,
1056            status_flags in 0u8..16u8,
1057            interval in 60u16..3600u16,
1058            age in 0u16..3600u16,
1059        ) {
1060            let mut data = [0u8; 12];
1061            data[0..2].copy_from_slice(&0x0002u16.to_le_bytes()); // header
1062            data[2..4].copy_from_slice(&interval.to_le_bytes());
1063            data[4..6].copy_from_slice(&age.to_le_bytes());
1064            data[6] = battery;
1065            data[7..9].copy_from_slice(&temp_raw.to_le_bytes());
1066            data[9..11].copy_from_slice(&humidity_raw.to_le_bytes());
1067            data[11] = status_flags;
1068
1069            let result = CurrentReading::from_bytes_aranet2(&data);
1070            prop_assert!(result.is_ok());
1071
1072            let reading = result.unwrap();
1073            prop_assert_eq!(reading.humidity, (humidity_raw / 10) as u8);
1074            prop_assert_eq!(reading.battery, battery);
1075            prop_assert_eq!(reading.interval, interval);
1076            prop_assert_eq!(reading.age, age);
1077        }
1078
1079        /// JSON serialization roundtrip should preserve all values.
1080        #[cfg(feature = "serde")]
1081        #[test]
1082        fn current_reading_json_roundtrip(
1083            co2 in 0u16..10000u16,
1084            temperature in -20.0f32..60.0f32,
1085            pressure in 800.0f32..1200.0f32,
1086            humidity in 0u8..100u8,
1087            battery in 0u8..100u8,
1088            interval in 60u16..3600u16,
1089            age in 0u16..3600u16,
1090        ) {
1091            let reading = CurrentReading {
1092                co2,
1093                temperature,
1094                pressure,
1095                humidity,
1096                battery,
1097                status: Status::Green,
1098                interval,
1099                age,
1100                captured_at: None,
1101                radon: None,
1102                radiation_rate: None,
1103                radiation_total: None,
1104                radon_avg_24h: None,
1105                radon_avg_7d: None,
1106                radon_avg_30d: None,
1107            };
1108
1109            let json = serde_json::to_string(&reading).unwrap();
1110            let parsed: CurrentReading = serde_json::from_str(&json).unwrap();
1111
1112            prop_assert_eq!(parsed.co2, reading.co2);
1113            prop_assert_eq!(parsed.humidity, reading.humidity);
1114            prop_assert_eq!(parsed.battery, reading.battery);
1115            prop_assert_eq!(parsed.interval, reading.interval);
1116            prop_assert_eq!(parsed.age, reading.age);
1117        }
1118    }
1119}