aranet_types/
lib.rs

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