1use std::collections::HashMap;
10
11use chrono::Utc;
12use serde::{Deserialize, Serialize};
13
14use crate::models::AssetResponse;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum AspectRatio {
19 FourThree,
21 SixteenNine,
23}
24
25const RATIO_TOLERANCE: f64 = 0.01;
27
28const RATIO_4_3: f64 = 4.0 / 3.0; const RATIO_16_9: f64 = 16.0 / 9.0; pub fn detect_aspect_ratio(width: u32, height: u32) -> Option<AspectRatio> {
62 if width == 0 || height == 0 {
63 return None;
64 }
65
66 let max_dim = width.max(height) as f64;
68 let min_dim = width.min(height) as f64;
69 let ratio = max_dim / min_dim;
70
71 if (ratio - RATIO_4_3).abs() < RATIO_TOLERANCE {
72 Some(AspectRatio::FourThree)
73 } else if (ratio - RATIO_16_9).abs() < RATIO_TOLERANCE {
74 Some(AspectRatio::SixteenNine)
75 } else {
76 None
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct LetterboxPair {
83 pub keeper: AssetResponse,
85 pub delete: AssetResponse,
87 pub timestamp: String,
89 pub camera: String,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Hash)]
95struct PairingKey {
96 timestamp_second: String,
98 make: String,
100 model: String,
102 gps_key: Option<String>,
104}
105
106impl PairingKey {
107 fn from_asset(asset: &AssetResponse) -> Option<Self> {
111 let exif = asset.exif_info.as_ref()?;
112
113 let timestamp = exif.date_time_original.as_ref()?;
115
116 let timestamp_second = if let Some(dot_pos) = timestamp.find('.') {
119 timestamp[..dot_pos].to_string()
120 } else if let Some(z_pos) = timestamp.find('Z') {
121 timestamp[..z_pos].to_string()
122 } else {
123 timestamp.clone()
124 };
125
126 let make = exif.make.clone()?;
128 let model = exif.model.clone()?;
129
130 let gps_key = match (exif.latitude, exif.longitude) {
132 (Some(lat), Some(lon)) => {
133 Some(format!("{:.4},{:.4}", lat, lon))
135 }
136 _ => None,
137 };
138
139 Some(Self {
140 timestamp_second,
141 make,
142 model,
143 gps_key,
144 })
145 }
146}
147
148fn is_iphone_asset(asset: &AssetResponse) -> bool {
150 let Some(exif) = &asset.exif_info else {
151 return false;
152 };
153
154 let is_apple = exif
155 .make
156 .as_ref()
157 .is_some_and(|make| make.to_lowercase().contains("apple"));
158
159 let is_iphone = exif
160 .model
161 .as_ref()
162 .is_some_and(|model| model.to_lowercase().contains("iphone"));
163
164 is_apple && is_iphone
165}
166
167fn get_asset_aspect_ratio(asset: &AssetResponse) -> Option<AspectRatio> {
169 let exif = asset.exif_info.as_ref()?;
170 let width = exif.exif_image_width?;
171 let height = exif.exif_image_height?;
172 detect_aspect_ratio(width, height)
173}
174
175pub fn find_letterbox_pairs(assets: &[AssetResponse]) -> Vec<LetterboxPair> {
196 let mut groups: HashMap<PairingKey, Vec<&AssetResponse>> = HashMap::new();
198
199 for asset in assets {
200 if !is_iphone_asset(asset) {
202 continue;
203 }
204
205 if asset.is_trashed {
207 continue;
208 }
209
210 if get_asset_aspect_ratio(asset).is_none() {
212 continue;
213 }
214
215 if let Some(key) = PairingKey::from_asset(asset) {
217 groups.entry(key).or_default().push(asset);
218 }
219 }
220
221 let mut pairs = Vec::new();
223
224 for (key, group_assets) in groups {
225 let mut four_three: Vec<&AssetResponse> = Vec::new();
227 let mut sixteen_nine: Vec<&AssetResponse> = Vec::new();
228
229 for asset in group_assets {
230 match get_asset_aspect_ratio(asset) {
231 Some(AspectRatio::FourThree) => four_three.push(asset),
232 Some(AspectRatio::SixteenNine) => sixteen_nine.push(asset),
233 None => {}
234 }
235 }
236
237 if four_three.len() == 1 && sixteen_nine.len() == 1 {
239 let keeper = four_three[0];
240 let delete = sixteen_nine[0];
241
242 pairs.push(LetterboxPair {
243 keeper: keeper.clone(),
244 delete: delete.clone(),
245 timestamp: key.timestamp_second.clone(),
246 camera: format!("{} {}", key.make, key.model),
247 });
248 }
249 }
251
252 pairs
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct LetterboxAnalysis {
261 pub pairs: Vec<LetterboxPair>,
263
264 pub total_pairs: usize,
266
267 pub total_space_recoverable: u64,
269
270 pub skipped_ambiguous: usize,
272
273 pub skipped_non_iphone: usize,
275
276 pub analyzed_at: String,
278}
279
280impl LetterboxAnalysis {
281 pub fn from_assets(assets: &[AssetResponse]) -> Self {
293 let skipped_non_iphone = assets
295 .iter()
296 .filter(|a| !is_iphone_asset(a))
297 .count();
298
299 let mut groups: HashMap<PairingKey, Vec<&AssetResponse>> = HashMap::new();
301 for asset in assets {
302 if !is_iphone_asset(asset) {
303 continue;
304 }
305 if asset.is_trashed {
306 continue;
307 }
308 if get_asset_aspect_ratio(asset).is_none() {
309 continue;
310 }
311 if let Some(key) = PairingKey::from_asset(asset) {
312 groups.entry(key).or_default().push(asset);
313 }
314 }
315
316 let skipped_ambiguous = groups
318 .values()
319 .filter(|group| {
320 let four_three_count = group
321 .iter()
322 .filter(|a| get_asset_aspect_ratio(a) == Some(AspectRatio::FourThree))
323 .count();
324 let sixteen_nine_count = group
325 .iter()
326 .filter(|a| get_asset_aspect_ratio(a) == Some(AspectRatio::SixteenNine))
327 .count();
328 (four_three_count > 1 && sixteen_nine_count > 0)
330 || (sixteen_nine_count > 1 && four_three_count > 0)
331 })
332 .count();
333
334 let pairs = find_letterbox_pairs(assets);
336
337 let total_space_recoverable = pairs
339 .iter()
340 .filter_map(|pair| {
341 pair.delete
342 .exif_info
343 .as_ref()
344 .and_then(|e| e.file_size_in_byte)
345 })
346 .sum();
347
348 Self {
349 total_pairs: pairs.len(),
350 pairs,
351 total_space_recoverable,
352 skipped_ambiguous,
353 skipped_non_iphone,
354 analyzed_at: Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(),
355 }
356 }
357
358 pub fn delete_ids(&self) -> Vec<&str> {
360 self.pairs.iter().map(|p| p.delete.id.as_str()).collect()
361 }
362
363 pub fn keeper_ids(&self) -> Vec<&str> {
365 self.pairs.iter().map(|p| p.keeper.id.as_str()).collect()
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372 use crate::models::{AssetType, ExifInfo};
373
374 fn mock_asset(
376 id: &str,
377 width: Option<u32>,
378 height: Option<u32>,
379 make: Option<&str>,
380 model: Option<&str>,
381 timestamp: Option<&str>,
382 lat: Option<f64>,
383 lon: Option<f64>,
384 ) -> AssetResponse {
385 let exif = ExifInfo {
386 exif_image_width: width,
387 exif_image_height: height,
388 make: make.map(String::from),
389 model: model.map(String::from),
390 date_time_original: timestamp.map(String::from),
391 latitude: lat,
392 longitude: lon,
393 city: None,
395 state: None,
396 country: None,
397 time_zone: None,
398 lens_model: None,
399 exposure_time: None,
400 f_number: None,
401 focal_length: None,
402 iso: None,
403 file_size_in_byte: None,
404 description: None,
405 rating: None,
406 orientation: None,
407 modify_date: None,
408 projection_type: None,
409 };
410
411 AssetResponse {
412 id: id.to_string(),
413 original_file_name: format!("{}.HEIC", id),
414 file_created_at: "2024-12-23T10:30:45Z".to_string(),
415 local_date_time: "2024-12-23T10:30:45".to_string(),
416 asset_type: AssetType::Image,
417 exif_info: Some(exif),
418 checksum: "abc123".to_string(),
419 is_trashed: false,
420 is_favorite: false,
421 is_archived: false,
422 has_metadata: true,
423 duration: "0:00:00.000000".to_string(),
424 owner_id: "owner-1".to_string(),
425 original_mime_type: Some("image/heic".to_string()),
426 duplicate_id: None,
427 thumbhash: None,
428 }
429 }
430
431 #[test]
434 fn test_detect_4_3_landscape() {
435 assert_eq!(
437 detect_aspect_ratio(5712, 4284),
438 Some(AspectRatio::FourThree)
439 );
440 }
441
442 #[test]
443 fn test_detect_4_3_portrait() {
444 assert_eq!(
446 detect_aspect_ratio(4284, 5712),
447 Some(AspectRatio::FourThree)
448 );
449 }
450
451 #[test]
452 fn test_detect_16_9_landscape() {
453 assert_eq!(
455 detect_aspect_ratio(5712, 3213),
456 Some(AspectRatio::SixteenNine)
457 );
458 }
459
460 #[test]
461 fn test_detect_16_9_portrait() {
462 assert_eq!(
464 detect_aspect_ratio(3213, 5712),
465 Some(AspectRatio::SixteenNine)
466 );
467 }
468
469 #[test]
470 fn test_detect_other_ratio_1_1() {
471 assert_eq!(detect_aspect_ratio(1000, 1000), None);
473 }
474
475 #[test]
476 fn test_detect_other_ratio_3_2() {
477 assert_eq!(detect_aspect_ratio(3000, 2000), None);
479 }
480
481 #[test]
482 fn test_detect_with_tolerance_4_3_edge() {
483 assert_eq!(
488 detect_aspect_ratio(1000, 745),
489 Some(AspectRatio::FourThree)
490 );
491 }
492
493 #[test]
494 fn test_detect_with_tolerance_16_9_edge() {
495 assert_eq!(
499 detect_aspect_ratio(1778, 1000),
500 Some(AspectRatio::SixteenNine)
501 );
502 }
503
504 #[test]
505 fn test_detect_zero_dimension() {
506 assert_eq!(detect_aspect_ratio(0, 100), None);
507 assert_eq!(detect_aspect_ratio(100, 0), None);
508 assert_eq!(detect_aspect_ratio(0, 0), None);
509 }
510
511 #[test]
512 fn test_detect_hd_16_9() {
513 assert_eq!(
515 detect_aspect_ratio(1920, 1080),
516 Some(AspectRatio::SixteenNine)
517 );
518 }
519
520 #[test]
521 fn test_detect_4k_16_9() {
522 assert_eq!(
524 detect_aspect_ratio(3840, 2160),
525 Some(AspectRatio::SixteenNine)
526 );
527 }
528
529 #[test]
532 fn test_find_pair_basic() {
533 let assets = vec![
535 mock_asset(
536 "asset-4-3",
537 Some(5712),
538 Some(4284),
539 Some("Apple"),
540 Some("iPhone 15 Pro Max"),
541 Some("2024-12-23T10:30:45.123Z"),
542 Some(51.5074),
543 Some(-0.1278),
544 ),
545 mock_asset(
546 "asset-16-9",
547 Some(5712),
548 Some(3213),
549 Some("Apple"),
550 Some("iPhone 15 Pro Max"),
551 Some("2024-12-23T10:30:45.456Z"),
552 Some(51.5074),
553 Some(-0.1278),
554 ),
555 ];
556
557 let pairs = find_letterbox_pairs(&assets);
558
559 assert_eq!(pairs.len(), 1);
560 assert_eq!(pairs[0].keeper.id, "asset-4-3");
561 assert_eq!(pairs[0].delete.id, "asset-16-9");
562 assert_eq!(pairs[0].camera, "Apple iPhone 15 Pro Max");
563 }
564
565 #[test]
566 fn test_skip_non_iphone() {
567 let assets = vec![
569 mock_asset(
570 "asset-4-3",
571 Some(4000),
572 Some(3000),
573 Some("Samsung"),
574 Some("Galaxy S23"),
575 Some("2024-12-23T10:30:45Z"),
576 None,
577 None,
578 ),
579 mock_asset(
580 "asset-16-9",
581 Some(4000),
582 Some(2250),
583 Some("Samsung"),
584 Some("Galaxy S23"),
585 Some("2024-12-23T10:30:45Z"),
586 None,
587 None,
588 ),
589 ];
590
591 let pairs = find_letterbox_pairs(&assets);
592 assert!(pairs.is_empty());
593 }
594
595 #[test]
596 fn test_skip_missing_timestamp() {
597 let assets = vec![
599 mock_asset(
600 "asset-4-3",
601 Some(5712),
602 Some(4284),
603 Some("Apple"),
604 Some("iPhone 15 Pro Max"),
605 None, None,
607 None,
608 ),
609 mock_asset(
610 "asset-16-9",
611 Some(5712),
612 Some(3213),
613 Some("Apple"),
614 Some("iPhone 15 Pro Max"),
615 None, None,
617 None,
618 ),
619 ];
620
621 let pairs = find_letterbox_pairs(&assets);
622 assert!(pairs.is_empty());
623 }
624
625 #[test]
626 fn test_skip_ambiguous_two_4_3() {
627 let assets = vec![
629 mock_asset(
630 "asset-4-3-a",
631 Some(5712),
632 Some(4284),
633 Some("Apple"),
634 Some("iPhone 15 Pro Max"),
635 Some("2024-12-23T10:30:45Z"),
636 None,
637 None,
638 ),
639 mock_asset(
640 "asset-4-3-b",
641 Some(5712),
642 Some(4284),
643 Some("Apple"),
644 Some("iPhone 15 Pro Max"),
645 Some("2024-12-23T10:30:45Z"),
646 None,
647 None,
648 ),
649 mock_asset(
650 "asset-16-9",
651 Some(5712),
652 Some(3213),
653 Some("Apple"),
654 Some("iPhone 15 Pro Max"),
655 Some("2024-12-23T10:30:45Z"),
656 None,
657 None,
658 ),
659 ];
660
661 let pairs = find_letterbox_pairs(&assets);
662 assert!(pairs.is_empty()); }
664
665 #[test]
666 fn test_skip_ambiguous_two_16_9() {
667 let assets = vec![
669 mock_asset(
670 "asset-4-3",
671 Some(5712),
672 Some(4284),
673 Some("Apple"),
674 Some("iPhone 15 Pro Max"),
675 Some("2024-12-23T10:30:45Z"),
676 None,
677 None,
678 ),
679 mock_asset(
680 "asset-16-9-a",
681 Some(5712),
682 Some(3213),
683 Some("Apple"),
684 Some("iPhone 15 Pro Max"),
685 Some("2024-12-23T10:30:45Z"),
686 None,
687 None,
688 ),
689 mock_asset(
690 "asset-16-9-b",
691 Some(5712),
692 Some(3213),
693 Some("Apple"),
694 Some("iPhone 15 Pro Max"),
695 Some("2024-12-23T10:30:45Z"),
696 None,
697 None,
698 ),
699 ];
700
701 let pairs = find_letterbox_pairs(&assets);
702 assert!(pairs.is_empty()); }
704
705 #[test]
706 fn test_multiple_pairs_different_timestamps() {
707 let assets = vec![
709 mock_asset(
711 "pair1-4-3",
712 Some(5712),
713 Some(4284),
714 Some("Apple"),
715 Some("iPhone 15 Pro Max"),
716 Some("2024-12-23T10:30:45Z"),
717 None,
718 None,
719 ),
720 mock_asset(
721 "pair1-16-9",
722 Some(5712),
723 Some(3213),
724 Some("Apple"),
725 Some("iPhone 15 Pro Max"),
726 Some("2024-12-23T10:30:45Z"),
727 None,
728 None,
729 ),
730 mock_asset(
732 "pair2-4-3",
733 Some(5712),
734 Some(4284),
735 Some("Apple"),
736 Some("iPhone 15 Pro Max"),
737 Some("2024-12-23T11:00:00Z"),
738 None,
739 None,
740 ),
741 mock_asset(
742 "pair2-16-9",
743 Some(5712),
744 Some(3213),
745 Some("Apple"),
746 Some("iPhone 15 Pro Max"),
747 Some("2024-12-23T11:00:00Z"),
748 None,
749 None,
750 ),
751 ];
752
753 let pairs = find_letterbox_pairs(&assets);
754 assert_eq!(pairs.len(), 2);
755 }
756
757 #[test]
758 fn test_gps_disambiguation() {
759 let assets = vec![
761 mock_asset(
762 "loc1-4-3",
763 Some(5712),
764 Some(4284),
765 Some("Apple"),
766 Some("iPhone 15 Pro Max"),
767 Some("2024-12-23T10:30:45Z"),
768 Some(51.5074), Some(-0.1278),
770 ),
771 mock_asset(
772 "loc2-16-9",
773 Some(5712),
774 Some(3213),
775 Some("Apple"),
776 Some("iPhone 15 Pro Max"),
777 Some("2024-12-23T10:30:45Z"),
778 Some(40.7128), Some(-74.0060),
780 ),
781 ];
782
783 let pairs = find_letterbox_pairs(&assets);
784 assert!(pairs.is_empty());
786 }
787
788 #[test]
789 fn test_gps_same_location_pairs() {
790 let assets = vec![
792 mock_asset(
793 "asset-4-3",
794 Some(5712),
795 Some(4284),
796 Some("Apple"),
797 Some("iPhone 15 Pro Max"),
798 Some("2024-12-23T10:30:45Z"),
799 Some(51.5074),
800 Some(-0.1278),
801 ),
802 mock_asset(
803 "asset-16-9",
804 Some(5712),
805 Some(3213),
806 Some("Apple"),
807 Some("iPhone 15 Pro Max"),
808 Some("2024-12-23T10:30:45Z"),
809 Some(51.5074), Some(-0.1278),
811 ),
812 ];
813
814 let pairs = find_letterbox_pairs(&assets);
815 assert_eq!(pairs.len(), 1);
816 }
817
818 #[test]
819 fn test_skip_trashed_assets() {
820 let mut asset_4_3 = mock_asset(
821 "asset-4-3",
822 Some(5712),
823 Some(4284),
824 Some("Apple"),
825 Some("iPhone 15 Pro Max"),
826 Some("2024-12-23T10:30:45Z"),
827 None,
828 None,
829 );
830 asset_4_3.is_trashed = true;
831
832 let asset_16_9 = mock_asset(
833 "asset-16-9",
834 Some(5712),
835 Some(3213),
836 Some("Apple"),
837 Some("iPhone 15 Pro Max"),
838 Some("2024-12-23T10:30:45Z"),
839 None,
840 None,
841 );
842
843 let assets = vec![asset_4_3, asset_16_9];
844 let pairs = find_letterbox_pairs(&assets);
845 assert!(pairs.is_empty()); }
847
848 #[test]
849 fn test_skip_missing_dimensions() {
850 let assets = vec![
852 mock_asset(
853 "asset-4-3",
854 None, None, Some("Apple"),
857 Some("iPhone 15 Pro Max"),
858 Some("2024-12-23T10:30:45Z"),
859 None,
860 None,
861 ),
862 mock_asset(
863 "asset-16-9",
864 Some(5712),
865 Some(3213),
866 Some("Apple"),
867 Some("iPhone 15 Pro Max"),
868 Some("2024-12-23T10:30:45Z"),
869 None,
870 None,
871 ),
872 ];
873
874 let pairs = find_letterbox_pairs(&assets);
875 assert!(pairs.is_empty());
876 }
877
878 #[test]
879 fn test_different_iphone_models_no_pair() {
880 let assets = vec![
882 mock_asset(
883 "asset-4-3",
884 Some(5712),
885 Some(4284),
886 Some("Apple"),
887 Some("iPhone 15 Pro Max"),
888 Some("2024-12-23T10:30:45Z"),
889 None,
890 None,
891 ),
892 mock_asset(
893 "asset-16-9",
894 Some(5712),
895 Some(3213),
896 Some("Apple"),
897 Some("iPhone 14 Pro"), Some("2024-12-23T10:30:45Z"),
899 None,
900 None,
901 ),
902 ];
903
904 let pairs = find_letterbox_pairs(&assets);
905 assert!(pairs.is_empty()); }
907
908 #[test]
909 fn test_only_4_3_no_pair() {
910 let assets = vec![mock_asset(
912 "asset-4-3",
913 Some(5712),
914 Some(4284),
915 Some("Apple"),
916 Some("iPhone 15 Pro Max"),
917 Some("2024-12-23T10:30:45Z"),
918 None,
919 None,
920 )];
921
922 let pairs = find_letterbox_pairs(&assets);
923 assert!(pairs.is_empty());
924 }
925
926 #[test]
927 fn test_only_16_9_no_pair() {
928 let assets = vec![mock_asset(
930 "asset-16-9",
931 Some(5712),
932 Some(3213),
933 Some("Apple"),
934 Some("iPhone 15 Pro Max"),
935 Some("2024-12-23T10:30:45Z"),
936 None,
937 None,
938 )];
939
940 let pairs = find_letterbox_pairs(&assets);
941 assert!(pairs.is_empty());
942 }
943
944 #[test]
945 fn test_subsecond_timestamp_handling() {
946 let assets = vec![
948 mock_asset(
949 "asset-4-3",
950 Some(5712),
951 Some(4284),
952 Some("Apple"),
953 Some("iPhone 15 Pro Max"),
954 Some("2024-12-23T10:30:45.123Z"),
955 None,
956 None,
957 ),
958 mock_asset(
959 "asset-16-9",
960 Some(5712),
961 Some(3213),
962 Some("Apple"),
963 Some("iPhone 15 Pro Max"),
964 Some("2024-12-23T10:30:45.999Z"), None,
966 None,
967 ),
968 ];
969
970 let pairs = find_letterbox_pairs(&assets);
971 assert_eq!(pairs.len(), 1); }
973
974 fn mock_asset_with_size(
978 id: &str,
979 width: Option<u32>,
980 height: Option<u32>,
981 make: Option<&str>,
982 model: Option<&str>,
983 timestamp: Option<&str>,
984 file_size: Option<u64>,
985 ) -> AssetResponse {
986 let exif = ExifInfo {
987 exif_image_width: width,
988 exif_image_height: height,
989 make: make.map(String::from),
990 model: model.map(String::from),
991 date_time_original: timestamp.map(String::from),
992 latitude: None,
993 longitude: None,
994 city: None,
995 state: None,
996 country: None,
997 time_zone: None,
998 lens_model: None,
999 exposure_time: None,
1000 f_number: None,
1001 focal_length: None,
1002 iso: None,
1003 file_size_in_byte: file_size,
1004 description: None,
1005 rating: None,
1006 orientation: None,
1007 modify_date: None,
1008 projection_type: None,
1009 };
1010
1011 AssetResponse {
1012 id: id.to_string(),
1013 original_file_name: format!("{}.HEIC", id),
1014 file_created_at: "2024-12-23T10:30:45Z".to_string(),
1015 local_date_time: "2024-12-23T10:30:45".to_string(),
1016 asset_type: AssetType::Image,
1017 exif_info: Some(exif),
1018 checksum: "abc123".to_string(),
1019 is_trashed: false,
1020 is_favorite: false,
1021 is_archived: false,
1022 has_metadata: true,
1023 duration: "0:00:00.000000".to_string(),
1024 owner_id: "owner-1".to_string(),
1025 original_mime_type: Some("image/heic".to_string()),
1026 duplicate_id: None,
1027 thumbhash: None,
1028 }
1029 }
1030
1031 #[test]
1032 fn test_letterbox_analysis_from_assets() {
1033 let assets = vec![
1034 mock_asset_with_size(
1036 "keeper-1",
1037 Some(5712),
1038 Some(4284), Some("Apple"),
1040 Some("iPhone 15 Pro Max"),
1041 Some("2024-12-23T10:30:45Z"),
1042 Some(10_000_000), ),
1044 mock_asset_with_size(
1045 "delete-1",
1046 Some(5712),
1047 Some(3213), Some("Apple"),
1049 Some("iPhone 15 Pro Max"),
1050 Some("2024-12-23T10:30:45Z"),
1051 Some(8_000_000), ),
1053 mock_asset_with_size(
1055 "android-1",
1056 Some(4000),
1057 Some(3000),
1058 Some("Samsung"),
1059 Some("Galaxy S23"),
1060 Some("2024-12-23T11:00:00Z"),
1061 Some(5_000_000),
1062 ),
1063 ];
1064
1065 let analysis = LetterboxAnalysis::from_assets(&assets);
1066
1067 assert_eq!(analysis.total_pairs, 1);
1068 assert_eq!(analysis.pairs.len(), 1);
1069 assert_eq!(analysis.total_space_recoverable, 8_000_000);
1070 assert_eq!(analysis.skipped_non_iphone, 1);
1071 assert_eq!(analysis.skipped_ambiguous, 0);
1072 assert!(!analysis.analyzed_at.is_empty());
1073 }
1074
1075 #[test]
1076 fn test_letterbox_analysis_delete_ids() {
1077 let assets = vec![
1078 mock_asset_with_size(
1079 "keeper-1",
1080 Some(5712),
1081 Some(4284),
1082 Some("Apple"),
1083 Some("iPhone 15 Pro Max"),
1084 Some("2024-12-23T10:30:45Z"),
1085 Some(10_000_000),
1086 ),
1087 mock_asset_with_size(
1088 "delete-1",
1089 Some(5712),
1090 Some(3213),
1091 Some("Apple"),
1092 Some("iPhone 15 Pro Max"),
1093 Some("2024-12-23T10:30:45Z"),
1094 Some(8_000_000),
1095 ),
1096 ];
1097
1098 let analysis = LetterboxAnalysis::from_assets(&assets);
1099 let delete_ids = analysis.delete_ids();
1100
1101 assert_eq!(delete_ids.len(), 1);
1102 assert_eq!(delete_ids[0], "delete-1");
1103 }
1104
1105 #[test]
1106 fn test_letterbox_analysis_keeper_ids() {
1107 let assets = vec![
1108 mock_asset_with_size(
1109 "keeper-1",
1110 Some(5712),
1111 Some(4284),
1112 Some("Apple"),
1113 Some("iPhone 15 Pro Max"),
1114 Some("2024-12-23T10:30:45Z"),
1115 Some(10_000_000),
1116 ),
1117 mock_asset_with_size(
1118 "delete-1",
1119 Some(5712),
1120 Some(3213),
1121 Some("Apple"),
1122 Some("iPhone 15 Pro Max"),
1123 Some("2024-12-23T10:30:45Z"),
1124 Some(8_000_000),
1125 ),
1126 ];
1127
1128 let analysis = LetterboxAnalysis::from_assets(&assets);
1129 let keeper_ids = analysis.keeper_ids();
1130
1131 assert_eq!(keeper_ids.len(), 1);
1132 assert_eq!(keeper_ids[0], "keeper-1");
1133 }
1134
1135 #[test]
1136 fn test_letterbox_analysis_serialization() {
1137 let assets = vec![
1138 mock_asset_with_size(
1139 "keeper-1",
1140 Some(5712),
1141 Some(4284),
1142 Some("Apple"),
1143 Some("iPhone 15 Pro Max"),
1144 Some("2024-12-23T10:30:45Z"),
1145 Some(10_000_000),
1146 ),
1147 mock_asset_with_size(
1148 "delete-1",
1149 Some(5712),
1150 Some(3213),
1151 Some("Apple"),
1152 Some("iPhone 15 Pro Max"),
1153 Some("2024-12-23T10:30:45Z"),
1154 Some(8_000_000),
1155 ),
1156 ];
1157
1158 let analysis = LetterboxAnalysis::from_assets(&assets);
1159
1160 let json = serde_json::to_string_pretty(&analysis).expect("should serialize to JSON");
1162 assert!(json.contains("total_pairs"));
1163 assert!(json.contains("total_space_recoverable"));
1164 assert!(json.contains("keeper"));
1165 assert!(json.contains("delete"));
1166
1167 let parsed: LetterboxAnalysis =
1169 serde_json::from_str(&json).expect("should deserialize from JSON");
1170 assert_eq!(parsed.total_pairs, analysis.total_pairs);
1171 assert_eq!(
1172 parsed.total_space_recoverable,
1173 analysis.total_space_recoverable
1174 );
1175 }
1176
1177 #[test]
1178 fn test_letterbox_analysis_empty() {
1179 let assets: Vec<AssetResponse> = vec![];
1180 let analysis = LetterboxAnalysis::from_assets(&assets);
1181
1182 assert_eq!(analysis.total_pairs, 0);
1183 assert_eq!(analysis.pairs.len(), 0);
1184 assert_eq!(analysis.total_space_recoverable, 0);
1185 assert_eq!(analysis.skipped_non_iphone, 0);
1186 assert_eq!(analysis.skipped_ambiguous, 0);
1187 }
1188}