1use ratatui::prelude::*;
7
8use aranet_core::settings::{DeviceSettings, RadonUnit, TemperatureUnit};
9use aranet_types::HistoryRecord;
10
11use super::theme::AppTheme;
12
13#[inline]
15fn celsius_to_fahrenheit(celsius: f32) -> f32 {
16 celsius * 9.0 / 5.0 + 32.0
17}
18
19#[inline]
21fn bq_to_pci(bq: u32) -> f32 {
22 bq as f32 * 0.027
23}
24
25#[must_use]
29pub fn format_temp_for_device(celsius: f32, settings: Option<&DeviceSettings>) -> String {
30 let use_fahrenheit = settings
31 .map(|s| s.temperature_unit == TemperatureUnit::Fahrenheit)
32 .unwrap_or(false);
33
34 if use_fahrenheit {
35 format!("{:.1}°F", celsius_to_fahrenheit(celsius))
36 } else {
37 format!("{:.1}°C", celsius)
38 }
39}
40
41#[must_use]
45pub fn format_radon_for_device(bq: u32, settings: Option<&DeviceSettings>) -> String {
46 let use_pci = settings
47 .map(|s| s.radon_unit == RadonUnit::PciL)
48 .unwrap_or(false);
49
50 if use_pci {
51 format!("{:.2} pCi/L", bq_to_pci(bq))
52 } else {
53 format!("{} Bq/m³", bq)
54 }
55}
56
57#[must_use]
59pub fn radon_unit_for_device(settings: Option<&DeviceSettings>) -> &'static str {
60 let use_pci = settings
61 .map(|s| s.radon_unit == RadonUnit::PciL)
62 .unwrap_or(false);
63
64 if use_pci { "pCi/L" } else { "Bq/m³" }
65}
66
67#[must_use]
71pub fn convert_radon_for_device(bq: u32, settings: Option<&DeviceSettings>) -> f32 {
72 let use_pci = settings
73 .map(|s| s.radon_unit == RadonUnit::PciL)
74 .unwrap_or(false);
75
76 if use_pci { bq_to_pci(bq) } else { bq as f32 }
77}
78
79#[must_use]
93pub fn sparkline_data(
94 history: &[HistoryRecord],
95 device_type: Option<aranet_types::DeviceType>,
96) -> Vec<u64> {
97 use aranet_types::DeviceType;
98
99 match device_type {
100 Some(DeviceType::AranetRadon) => {
101 history
103 .iter()
104 .filter_map(|record| record.radon)
105 .map(u64::from)
106 .collect()
107 }
108 Some(DeviceType::AranetRadiation) => {
109 history
111 .iter()
112 .filter_map(|record| record.radiation_rate)
113 .map(|r| r as u64)
114 .collect()
115 }
116 _ => {
117 history
119 .iter()
120 .filter(|record| record.co2 > 0)
121 .map(|record| u64::from(record.co2))
122 .collect()
123 }
124 }
125}
126
127#[must_use]
142pub fn resample_sparkline_data(data: &[u64], target_width: usize) -> Vec<u64> {
143 if data.is_empty() || target_width == 0 {
144 return Vec::new();
145 }
146
147 if data.len() == target_width {
148 return data.to_vec();
149 }
150
151 let mut result = Vec::with_capacity(target_width);
152
153 if data.len() < target_width {
154 for i in 0..target_width {
157 let src_idx = i * (data.len() - 1) / (target_width - 1).max(1);
158 result.push(data[src_idx.min(data.len() - 1)]);
159 }
160 } else {
161 let bucket_size = data.len() as f64 / target_width as f64;
163 for i in 0..target_width {
164 let start = (i as f64 * bucket_size) as usize;
165 let end = ((i + 1) as f64 * bucket_size) as usize;
166 let end = end.min(data.len());
167
168 if start < end {
169 let sum: u64 = data[start..end].iter().sum();
170 let avg = sum / (end - start) as u64;
171 result.push(avg);
172 } else if start < data.len() {
173 result.push(data[start]);
174 }
175 }
176 }
177
178 result
179}
180
181pub fn trend_indicator(
184 theme: &AppTheme,
185 current: i32,
186 previous: i32,
187 threshold: i32,
188) -> (&'static str, Color) {
189 let diff = current - previous;
190 if diff > threshold {
191 ("↑", theme.trend_color(diff, threshold))
192 } else if diff < -threshold {
193 ("↓", theme.trend_color(diff, threshold))
194 } else {
195 ("→", theme.trend_color(diff, threshold))
196 }
197}
198
199pub fn co2_trend(
201 theme: &AppTheme,
202 current: u16,
203 previous: Option<u16>,
204) -> Option<(&'static str, Color)> {
205 previous.map(|prev| trend_indicator(theme, current as i32, prev as i32, 20))
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[test]
217 fn test_celsius_to_fahrenheit_freezing() {
218 let result = celsius_to_fahrenheit(0.0);
219 assert!((result - 32.0).abs() < 0.01);
220 }
221
222 #[test]
223 fn test_celsius_to_fahrenheit_boiling() {
224 let result = celsius_to_fahrenheit(100.0);
225 assert!((result - 212.0).abs() < 0.01);
226 }
227
228 #[test]
229 fn test_celsius_to_fahrenheit_negative() {
230 let result = celsius_to_fahrenheit(-40.0);
232 assert!((result - (-40.0)).abs() < 0.01);
233 }
234
235 #[test]
240 fn test_bq_to_pci_zero() {
241 let result = bq_to_pci(0);
242 assert!((result - 0.0).abs() < 0.001);
243 }
244
245 #[test]
246 fn test_bq_to_pci_100() {
247 let result = bq_to_pci(100);
249 assert!((result - 2.7).abs() < 0.01);
250 }
251
252 #[test]
257 fn test_format_temp_no_settings_defaults_celsius() {
258 let result = format_temp_for_device(20.5, None);
259 assert_eq!(result, "20.5°C");
260 }
261
262 #[test]
263 fn test_format_temp_celsius_setting() {
264 let settings = DeviceSettings {
265 temperature_unit: TemperatureUnit::Celsius,
266 ..Default::default()
267 };
268 let result = format_temp_for_device(20.5, Some(&settings));
269 assert_eq!(result, "20.5°C");
270 }
271
272 #[test]
273 fn test_format_temp_fahrenheit_setting() {
274 let settings = DeviceSettings {
275 temperature_unit: TemperatureUnit::Fahrenheit,
276 ..Default::default()
277 };
278 let result = format_temp_for_device(20.0, Some(&settings));
279 assert_eq!(result, "68.0°F");
281 }
282
283 #[test]
288 fn test_format_radon_no_settings_defaults_bq() {
289 let result = format_radon_for_device(150, None);
290 assert_eq!(result, "150 Bq/m³");
291 }
292
293 #[test]
294 fn test_format_radon_bq_setting() {
295 let settings = DeviceSettings {
296 radon_unit: RadonUnit::BqM3,
297 ..Default::default()
298 };
299 let result = format_radon_for_device(150, Some(&settings));
300 assert_eq!(result, "150 Bq/m³");
301 }
302
303 #[test]
304 fn test_format_radon_pci_setting() {
305 let settings = DeviceSettings {
306 radon_unit: RadonUnit::PciL,
307 ..Default::default()
308 };
309 let result = format_radon_for_device(100, Some(&settings));
310 assert_eq!(result, "2.70 pCi/L");
312 }
313
314 #[test]
319 fn test_resample_empty_data() {
320 let result = resample_sparkline_data(&[], 10);
321 assert!(result.is_empty());
322 }
323
324 #[test]
325 fn test_resample_zero_width() {
326 let result = resample_sparkline_data(&[1, 2, 3], 0);
327 assert!(result.is_empty());
328 }
329
330 #[test]
331 fn test_resample_same_size() {
332 let data = vec![1, 2, 3, 4, 5];
333 let result = resample_sparkline_data(&data, 5);
334 assert_eq!(result, data);
335 }
336
337 #[test]
338 fn test_resample_upsample() {
339 let data = vec![100, 200];
340 let result = resample_sparkline_data(&data, 4);
341 assert_eq!(result.len(), 4);
342 }
343
344 #[test]
345 fn test_resample_downsample() {
346 let data = vec![100, 100, 200, 200];
347 let result = resample_sparkline_data(&data, 2);
348 assert_eq!(result.len(), 2);
349 assert_eq!(result[0], 100);
351 assert_eq!(result[1], 200);
352 }
353
354 #[test]
359 fn test_trend_indicator_rising() {
360 let theme = AppTheme::dark();
361 let (arrow, color) = trend_indicator(&theme, 500, 400, 20);
362 assert_eq!(arrow, "↑");
363 assert_eq!(color, theme.trend_rising);
364 }
365
366 #[test]
367 fn test_trend_indicator_falling() {
368 let theme = AppTheme::dark();
369 let (arrow, color) = trend_indicator(&theme, 400, 500, 20);
370 assert_eq!(arrow, "↓");
371 assert_eq!(color, theme.trend_falling);
372 }
373
374 #[test]
375 fn test_trend_indicator_stable() {
376 let theme = AppTheme::dark();
377 let (arrow, color) = trend_indicator(&theme, 500, 505, 20);
378 assert_eq!(arrow, "→");
379 assert_eq!(color, theme.trend_stable);
380 }
381
382 #[test]
387 fn test_co2_trend_no_previous() {
388 let theme = AppTheme::dark();
389 let result = co2_trend(&theme, 800, None);
390 assert!(result.is_none());
391 }
392
393 #[test]
394 fn test_co2_trend_rising() {
395 let theme = AppTheme::dark();
396 let result = co2_trend(&theme, 850, Some(800));
397 assert!(result.is_some());
398 let (arrow, color) = result.expect("co2_trend should return Some for rising trend");
399 assert_eq!(arrow, "↑");
400 assert_eq!(color, theme.trend_rising);
401 }
402
403 #[test]
404 fn test_co2_trend_falling() {
405 let theme = AppTheme::dark();
406 let result = co2_trend(&theme, 750, Some(800));
407 assert!(result.is_some());
408 let (arrow, color) = result.expect("co2_trend should return Some for falling trend");
409 assert_eq!(arrow, "↓");
410 assert_eq!(color, theme.trend_falling);
411 }
412
413 #[test]
414 fn test_co2_trend_stable() {
415 let theme = AppTheme::dark();
416 let result = co2_trend(&theme, 805, Some(800));
417 assert!(result.is_some());
418 let (arrow, _) = result.expect("co2_trend should return Some for stable trend");
419 assert_eq!(arrow, "→");
420 }
421
422 #[test]
427 fn test_radon_unit_no_settings() {
428 let result = radon_unit_for_device(None);
429 assert_eq!(result, "Bq/m³");
430 }
431
432 #[test]
433 fn test_radon_unit_bq_setting() {
434 let settings = DeviceSettings {
435 radon_unit: RadonUnit::BqM3,
436 ..Default::default()
437 };
438 let result = radon_unit_for_device(Some(&settings));
439 assert_eq!(result, "Bq/m³");
440 }
441
442 #[test]
443 fn test_radon_unit_pci_setting() {
444 let settings = DeviceSettings {
445 radon_unit: RadonUnit::PciL,
446 ..Default::default()
447 };
448 let result = radon_unit_for_device(Some(&settings));
449 assert_eq!(result, "pCi/L");
450 }
451
452 #[test]
457 fn test_convert_radon_no_settings() {
458 let result = convert_radon_for_device(100, None);
459 assert_eq!(result, 100.0);
460 }
461
462 #[test]
463 fn test_convert_radon_bq_setting() {
464 let settings = DeviceSettings {
465 radon_unit: RadonUnit::BqM3,
466 ..Default::default()
467 };
468 let result = convert_radon_for_device(100, Some(&settings));
469 assert_eq!(result, 100.0);
470 }
471
472 #[test]
473 fn test_convert_radon_pci_setting() {
474 let settings = DeviceSettings {
475 radon_unit: RadonUnit::PciL,
476 ..Default::default()
477 };
478 let result = convert_radon_for_device(100, Some(&settings));
479 assert!((result - 2.7).abs() < 0.01);
481 }
482
483 #[test]
488 fn test_sparkline_data_empty() {
489 let result = sparkline_data(&[], None);
490 assert!(result.is_empty());
491 }
492
493 #[test]
494 fn test_sparkline_data_aranet4() {
495 use aranet_types::DeviceType;
496 use time::OffsetDateTime;
497
498 let history = vec![
499 HistoryRecord {
500 timestamp: OffsetDateTime::now_utc(),
501 co2: 800,
502 temperature: 22.5,
503 humidity: 45,
504 pressure: 1013.0,
505 radon: None,
506 radiation_rate: None,
507 radiation_total: None,
508 },
509 HistoryRecord {
510 timestamp: OffsetDateTime::now_utc(),
511 co2: 850,
512 temperature: 22.5,
513 humidity: 45,
514 pressure: 1013.0,
515 radon: None,
516 radiation_rate: None,
517 radiation_total: None,
518 },
519 ];
520
521 let result = sparkline_data(&history, Some(DeviceType::Aranet4));
522 assert_eq!(result, vec![800, 850]);
523 }
524
525 #[test]
526 fn test_sparkline_data_radon() {
527 use aranet_types::DeviceType;
528 use time::OffsetDateTime;
529
530 let history = vec![
531 HistoryRecord {
532 timestamp: OffsetDateTime::now_utc(),
533 co2: 0,
534 temperature: 22.5,
535 humidity: 45,
536 pressure: 1013.0,
537 radon: Some(100),
538 radiation_rate: None,
539 radiation_total: None,
540 },
541 HistoryRecord {
542 timestamp: OffsetDateTime::now_utc(),
543 co2: 0,
544 temperature: 22.5,
545 humidity: 45,
546 pressure: 1013.0,
547 radon: Some(150),
548 radiation_rate: None,
549 radiation_total: None,
550 },
551 ];
552
553 let result = sparkline_data(&history, Some(DeviceType::AranetRadon));
554 assert_eq!(result, vec![100, 150]);
555 }
556
557 #[test]
558 fn test_sparkline_data_filters_zero_co2() {
559 use time::OffsetDateTime;
560
561 let history = vec![
562 HistoryRecord {
563 timestamp: OffsetDateTime::now_utc(),
564 co2: 0, temperature: 22.5,
566 humidity: 45,
567 pressure: 1013.0,
568 radon: None,
569 radiation_rate: None,
570 radiation_total: None,
571 },
572 HistoryRecord {
573 timestamp: OffsetDateTime::now_utc(),
574 co2: 800,
575 temperature: 22.5,
576 humidity: 45,
577 pressure: 1013.0,
578 radon: None,
579 radiation_rate: None,
580 radiation_total: None,
581 },
582 ];
583
584 let result = sparkline_data(&history, None);
585 assert_eq!(result, vec![800]); }
587}