1#[allow(clippy::wildcard_imports)]
4use super::*;
5
6impl FilterGraphBuilder {
7 #[must_use]
11 pub fn trim(mut self, start: f64, end: f64) -> Self {
12 self.steps.push(FilterStep::Trim { start, end });
13 self
14 }
15
16 #[must_use]
23 pub fn scale(mut self, width: u32, height: u32, algorithm: ScaleAlgorithm) -> Self {
24 self.steps.push(FilterStep::Scale {
25 width,
26 height,
27 algorithm,
28 });
29 self
30 }
31
32 #[must_use]
34 pub fn crop(mut self, x: u32, y: u32, width: u32, height: u32) -> Self {
35 self.steps.push(FilterStep::Crop {
36 x,
37 y,
38 width,
39 height,
40 });
41 self
42 }
43
44 #[must_use]
46 pub fn overlay(mut self, x: i32, y: i32) -> Self {
47 self.steps.push(FilterStep::Overlay { x, y });
48 self
49 }
50
51 #[must_use]
54 pub fn fade_in(mut self, start_sec: f64, duration_sec: f64) -> Self {
55 self.steps.push(FilterStep::FadeIn {
56 start: start_sec,
57 duration: duration_sec,
58 });
59 self
60 }
61
62 #[must_use]
65 pub fn fade_out(mut self, start_sec: f64, duration_sec: f64) -> Self {
66 self.steps.push(FilterStep::FadeOut {
67 start: start_sec,
68 duration: duration_sec,
69 });
70 self
71 }
72
73 #[must_use]
76 pub fn fade_in_white(mut self, start_sec: f64, duration_sec: f64) -> Self {
77 self.steps.push(FilterStep::FadeInWhite {
78 start: start_sec,
79 duration: duration_sec,
80 });
81 self
82 }
83
84 #[must_use]
87 pub fn fade_out_white(mut self, start_sec: f64, duration_sec: f64) -> Self {
88 self.steps.push(FilterStep::FadeOutWhite {
89 start: start_sec,
90 duration: duration_sec,
91 });
92 self
93 }
94
95 #[must_use]
102 pub fn rotate(mut self, angle_degrees: f64, fill_color: &str) -> Self {
103 self.steps.push(FilterStep::Rotate {
104 angle_degrees,
105 fill_color: fill_color.to_owned(),
106 });
107 self
108 }
109
110 #[must_use]
112 pub fn tone_map(mut self, algorithm: ToneMap) -> Self {
113 self.steps.push(FilterStep::ToneMap(algorithm));
114 self
115 }
116
117 #[must_use]
127 pub fn lut3d(mut self, path: &str) -> Self {
128 self.steps.push(FilterStep::Lut3d {
129 path: path.to_owned(),
130 });
131 self
132 }
133
134 #[must_use]
146 pub fn eq(mut self, brightness: f32, contrast: f32, saturation: f32) -> Self {
147 self.steps.push(FilterStep::Eq {
148 brightness,
149 contrast,
150 saturation,
151 });
152 self
153 }
154
155 #[must_use]
165 pub fn curves(
166 mut self,
167 master: Vec<(f32, f32)>,
168 r: Vec<(f32, f32)>,
169 g: Vec<(f32, f32)>,
170 b: Vec<(f32, f32)>,
171 ) -> Self {
172 self.steps.push(FilterStep::Curves { master, r, g, b });
173 self
174 }
175
176 #[must_use]
191 pub fn white_balance(mut self, temperature_k: u32, tint: f32) -> Self {
192 self.steps.push(FilterStep::WhiteBalance {
193 temperature_k,
194 tint,
195 });
196 self
197 }
198
199 #[must_use]
208 pub fn hue(mut self, degrees: f32) -> Self {
209 self.steps.push(FilterStep::Hue { degrees });
210 self
211 }
212
213 #[must_use]
223 pub fn gamma(mut self, r: f32, g: f32, b: f32) -> Self {
224 self.steps.push(FilterStep::Gamma { r, g, b });
225 self
226 }
227
228 #[must_use]
244 pub fn three_way_cc(mut self, lift: Rgb, gamma: Rgb, gain: Rgb) -> Self {
245 self.steps
246 .push(FilterStep::ThreeWayCC { lift, gamma, gain });
247 self
248 }
249
250 #[must_use]
265 pub fn vignette(mut self, angle: f32, x0: f32, y0: f32) -> Self {
266 self.steps.push(FilterStep::Vignette { angle, x0, y0 });
267 self
268 }
269
270 #[must_use]
272 pub fn hflip(mut self) -> Self {
273 self.steps.push(FilterStep::HFlip);
274 self
275 }
276
277 #[must_use]
279 pub fn vflip(mut self) -> Self {
280 self.steps.push(FilterStep::VFlip);
281 self
282 }
283
284 #[must_use]
289 pub fn reverse(mut self) -> Self {
290 self.steps.push(FilterStep::Reverse);
291 self
292 }
293
294 #[must_use]
308 pub fn pad(mut self, width: u32, height: u32, x: i32, y: i32, color: &str) -> Self {
309 self.steps.push(FilterStep::Pad {
310 width,
311 height,
312 x,
313 y,
314 color: color.to_owned(),
315 });
316 self
317 }
318
319 #[must_use]
334 pub fn fit_to_aspect(mut self, width: u32, height: u32, color: &str) -> Self {
335 self.steps.push(FilterStep::FitToAspect {
336 width,
337 height,
338 color: color.to_owned(),
339 });
340 self
341 }
342
343 #[must_use]
354 pub fn gblur(mut self, sigma: f32) -> Self {
355 self.steps.push(FilterStep::GBlur { sigma });
356 self
357 }
358
359 #[must_use]
371 pub fn unsharp(mut self, luma_strength: f32, chroma_strength: f32) -> Self {
372 self.steps.push(FilterStep::Unsharp {
373 luma_strength,
374 chroma_strength,
375 });
376 self
377 }
378
379 #[must_use]
389 pub fn hqdn3d(
390 mut self,
391 luma_spatial: f32,
392 chroma_spatial: f32,
393 luma_tmp: f32,
394 chroma_tmp: f32,
395 ) -> Self {
396 self.steps.push(FilterStep::Hqdn3d {
397 luma_spatial,
398 chroma_spatial,
399 luma_tmp,
400 chroma_tmp,
401 });
402 self
403 }
404
405 #[must_use]
412 pub fn nlmeans(mut self, strength: f32) -> Self {
413 self.steps.push(FilterStep::Nlmeans { strength });
414 self
415 }
416
417 #[must_use]
422 pub fn yadif(mut self, mode: YadifMode) -> Self {
423 self.steps.push(FilterStep::Yadif { mode });
424 self
425 }
426
427 #[must_use]
437 pub fn xfade(mut self, transition: XfadeTransition, duration: f64, offset: f64) -> Self {
438 self.steps.push(FilterStep::XFade {
439 transition,
440 duration,
441 offset,
442 });
443 self
444 }
445
446 #[must_use]
460 pub fn join_with_dissolve(
461 mut self,
462 clip_a_end_sec: f64,
463 clip_b_start_sec: f64,
464 dissolve_dur_sec: f64,
465 ) -> Self {
466 self.steps.push(FilterStep::JoinWithDissolve {
467 clip_a_end: clip_a_end_sec,
468 clip_b_start: clip_b_start_sec,
469 dissolve_dur: dissolve_dur_sec,
470 });
471 self
472 }
473
474 #[must_use]
486 pub fn speed(mut self, factor: f64) -> Self {
487 self.steps.push(FilterStep::Speed { factor });
488 self
489 }
490
491 #[must_use]
497 pub fn concat_video(mut self, n_segments: u32) -> Self {
498 self.steps.push(FilterStep::ConcatVideo { n: n_segments });
499 self
500 }
501
502 #[must_use]
511 pub fn freeze_frame(mut self, pts_sec: f64, duration_sec: f64) -> Self {
512 self.steps.push(FilterStep::FreezeFrame {
513 pts: pts_sec,
514 duration: duration_sec,
515 });
516 self
517 }
518
519 #[must_use]
524 pub fn drawtext(mut self, opts: DrawTextOptions) -> Self {
525 self.steps.push(FilterStep::DrawText { opts });
526 self
527 }
528
529 #[must_use]
542 pub fn ticker(
543 mut self,
544 text: &str,
545 y: &str,
546 speed_px_per_sec: f32,
547 font_size: u32,
548 font_color: &str,
549 ) -> Self {
550 self.steps.push(FilterStep::Ticker {
551 text: text.to_owned(),
552 y: y.to_owned(),
553 speed_px_per_sec,
554 font_size,
555 font_color: font_color.to_owned(),
556 });
557 self
558 }
559
560 #[must_use]
569 pub fn subtitles_srt(mut self, srt_path: &str) -> Self {
570 self.steps.push(FilterStep::SubtitlesSrt {
571 path: srt_path.to_owned(),
572 });
573 self
574 }
575
576 #[must_use]
587 pub fn subtitles_ass(mut self, ass_path: &str) -> Self {
588 self.steps.push(FilterStep::SubtitlesAss {
589 path: ass_path.to_owned(),
590 });
591 self
592 }
593
594 #[must_use]
608 pub fn overlay_image(mut self, path: &str, x: &str, y: &str, opacity: f32) -> Self {
609 self.steps.push(FilterStep::OverlayImage {
610 path: path.to_owned(),
611 x: x.to_owned(),
612 y: y.to_owned(),
613 opacity,
614 });
615 self
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn filter_step_scale_should_produce_correct_args() {
625 let step = FilterStep::Scale {
626 width: 1280,
627 height: 720,
628 algorithm: ScaleAlgorithm::Fast,
629 };
630 assert_eq!(step.filter_name(), "scale");
631 assert_eq!(step.args(), "w=1280:h=720:flags=fast_bilinear");
632 }
633
634 #[test]
635 fn filter_step_scale_lanczos_should_produce_lanczos_flags() {
636 let step = FilterStep::Scale {
637 width: 1920,
638 height: 1080,
639 algorithm: ScaleAlgorithm::Lanczos,
640 };
641 assert_eq!(step.args(), "w=1920:h=1080:flags=lanczos");
642 }
643
644 #[test]
645 fn filter_step_trim_should_produce_correct_args() {
646 let step = FilterStep::Trim {
647 start: 10.0,
648 end: 30.0,
649 };
650 assert_eq!(step.filter_name(), "trim");
651 assert_eq!(step.args(), "start=10:end=30");
652 }
653
654 #[test]
655 fn tone_map_variants_should_have_correct_names() {
656 assert_eq!(ToneMap::Hable.as_str(), "hable");
657 assert_eq!(ToneMap::Reinhard.as_str(), "reinhard");
658 assert_eq!(ToneMap::Mobius.as_str(), "mobius");
659 }
660
661 #[test]
662 fn filter_step_overlay_should_produce_correct_args() {
663 let step = FilterStep::Overlay { x: 10, y: 20 };
664 assert_eq!(step.filter_name(), "overlay");
665 assert_eq!(step.args(), "x=10:y=20");
666 }
667
668 #[test]
669 fn filter_step_crop_should_produce_correct_args() {
670 let step = FilterStep::Crop {
671 x: 0,
672 y: 0,
673 width: 640,
674 height: 360,
675 };
676 assert_eq!(step.filter_name(), "crop");
677 assert_eq!(step.args(), "x=0:y=0:w=640:h=360");
678 }
679
680 #[test]
681 fn filter_step_fade_in_should_produce_correct_filter_name() {
682 let step = FilterStep::FadeIn {
683 start: 0.0,
684 duration: 1.5,
685 };
686 assert_eq!(step.filter_name(), "fade");
687 }
688
689 #[test]
690 fn filter_step_fade_in_should_produce_correct_args() {
691 let step = FilterStep::FadeIn {
692 start: 0.0,
693 duration: 1.5,
694 };
695 assert_eq!(step.args(), "type=in:start_time=0:duration=1.5");
696 }
697
698 #[test]
699 fn filter_step_fade_in_with_nonzero_start_should_produce_correct_args() {
700 let step = FilterStep::FadeIn {
701 start: 2.0,
702 duration: 1.0,
703 };
704 assert_eq!(step.args(), "type=in:start_time=2:duration=1");
705 }
706
707 #[test]
708 fn filter_step_fade_out_should_produce_correct_filter_name() {
709 let step = FilterStep::FadeOut {
710 start: 8.5,
711 duration: 1.5,
712 };
713 assert_eq!(step.filter_name(), "fade");
714 }
715
716 #[test]
717 fn filter_step_fade_out_should_produce_correct_args() {
718 let step = FilterStep::FadeOut {
719 start: 8.5,
720 duration: 1.5,
721 };
722 assert_eq!(step.args(), "type=out:start_time=8.5:duration=1.5");
723 }
724
725 #[test]
726 fn builder_fade_in_with_valid_params_should_succeed() {
727 let result = FilterGraph::builder().fade_in(0.0, 1.5).build();
728 assert!(
729 result.is_ok(),
730 "fade_in(0.0, 1.5) must build successfully, got {result:?}"
731 );
732 }
733
734 #[test]
735 fn builder_fade_out_with_valid_params_should_succeed() {
736 let result = FilterGraph::builder().fade_out(8.5, 1.5).build();
737 assert!(
738 result.is_ok(),
739 "fade_out(8.5, 1.5) must build successfully, got {result:?}"
740 );
741 }
742
743 #[test]
744 fn builder_fade_in_with_zero_duration_should_return_invalid_config() {
745 let result = FilterGraph::builder().fade_in(0.0, 0.0).build();
746 assert!(
747 matches!(result, Err(FilterError::InvalidConfig { .. })),
748 "expected InvalidConfig for zero duration, got {result:?}"
749 );
750 if let Err(FilterError::InvalidConfig { reason }) = result {
751 assert!(
752 reason.contains("duration"),
753 "reason should mention duration: {reason}"
754 );
755 }
756 }
757
758 #[test]
759 fn builder_fade_out_with_negative_duration_should_return_invalid_config() {
760 let result = FilterGraph::builder().fade_out(0.0, -1.0).build();
761 assert!(
762 matches!(result, Err(FilterError::InvalidConfig { .. })),
763 "expected InvalidConfig for negative duration, got {result:?}"
764 );
765 }
766
767 #[test]
768 fn filter_step_fade_in_white_should_produce_correct_filter_name() {
769 let step = FilterStep::FadeInWhite {
770 start: 0.0,
771 duration: 1.0,
772 };
773 assert_eq!(step.filter_name(), "fade");
774 }
775
776 #[test]
777 fn filter_step_fade_in_white_should_produce_correct_args() {
778 let step = FilterStep::FadeInWhite {
779 start: 0.0,
780 duration: 1.0,
781 };
782 assert_eq!(step.args(), "type=in:start_time=0:duration=1:color=white");
783 }
784
785 #[test]
786 fn filter_step_fade_in_white_with_nonzero_start_should_produce_correct_args() {
787 let step = FilterStep::FadeInWhite {
788 start: 2.5,
789 duration: 1.0,
790 };
791 assert_eq!(step.args(), "type=in:start_time=2.5:duration=1:color=white");
792 }
793
794 #[test]
795 fn filter_step_fade_out_white_should_produce_correct_filter_name() {
796 let step = FilterStep::FadeOutWhite {
797 start: 8.0,
798 duration: 1.0,
799 };
800 assert_eq!(step.filter_name(), "fade");
801 }
802
803 #[test]
804 fn filter_step_fade_out_white_should_produce_correct_args() {
805 let step = FilterStep::FadeOutWhite {
806 start: 8.0,
807 duration: 1.0,
808 };
809 assert_eq!(step.args(), "type=out:start_time=8:duration=1:color=white");
810 }
811
812 #[test]
813 fn builder_fade_in_white_with_valid_params_should_succeed() {
814 let result = FilterGraph::builder().fade_in_white(0.0, 1.0).build();
815 assert!(
816 result.is_ok(),
817 "fade_in_white(0.0, 1.0) must build successfully, got {result:?}"
818 );
819 }
820
821 #[test]
822 fn builder_fade_out_white_with_valid_params_should_succeed() {
823 let result = FilterGraph::builder().fade_out_white(8.0, 1.0).build();
824 assert!(
825 result.is_ok(),
826 "fade_out_white(8.0, 1.0) must build successfully, got {result:?}"
827 );
828 }
829
830 #[test]
831 fn builder_fade_in_white_with_zero_duration_should_return_invalid_config() {
832 let result = FilterGraph::builder().fade_in_white(0.0, 0.0).build();
833 assert!(
834 matches!(result, Err(FilterError::InvalidConfig { .. })),
835 "expected InvalidConfig for zero duration, got {result:?}"
836 );
837 }
838
839 #[test]
840 fn builder_fade_out_white_with_negative_duration_should_return_invalid_config() {
841 let result = FilterGraph::builder().fade_out_white(0.0, -1.0).build();
842 assert!(
843 matches!(result, Err(FilterError::InvalidConfig { .. })),
844 "expected InvalidConfig for negative duration, got {result:?}"
845 );
846 }
847
848 #[test]
849 fn filter_step_rotate_should_produce_correct_args() {
850 let step = FilterStep::Rotate {
851 angle_degrees: 90.0,
852 fill_color: "black".to_owned(),
853 };
854 assert_eq!(step.filter_name(), "rotate");
855 assert_eq!(
856 step.args(),
857 format!("angle={}:fillcolor=black", 90_f64.to_radians())
858 );
859 }
860
861 #[test]
862 fn filter_step_rotate_transparent_fill_should_produce_correct_args() {
863 let step = FilterStep::Rotate {
864 angle_degrees: 45.0,
865 fill_color: "0x00000000".to_owned(),
866 };
867 assert_eq!(step.filter_name(), "rotate");
868 let args = step.args();
869 assert!(
870 args.contains("fillcolor=0x00000000"),
871 "args should contain transparent fill: {args}"
872 );
873 }
874
875 #[test]
876 fn filter_step_tone_map_should_produce_correct_args() {
877 let step = FilterStep::ToneMap(ToneMap::Hable);
878 assert_eq!(step.filter_name(), "tonemap");
879 assert_eq!(step.args(), "tonemap=hable");
880 }
881
882 #[test]
883 fn filter_step_lut3d_should_produce_correct_filter_name() {
884 let step = FilterStep::Lut3d {
885 path: "grade.cube".to_owned(),
886 };
887 assert_eq!(step.filter_name(), "lut3d");
888 }
889
890 #[test]
891 fn filter_step_lut3d_should_produce_correct_args() {
892 let step = FilterStep::Lut3d {
893 path: "grade.cube".to_owned(),
894 };
895 assert_eq!(step.args(), "file=grade.cube:interp=trilinear");
896 }
897
898 #[test]
899 fn builder_lut3d_with_unsupported_extension_should_return_invalid_config() {
900 let result = FilterGraph::builder().lut3d("color_grade.txt").build();
901 assert!(
902 matches!(result, Err(FilterError::InvalidConfig { .. })),
903 "expected InvalidConfig for unsupported extension, got {result:?}"
904 );
905 if let Err(FilterError::InvalidConfig { reason }) = result {
906 assert!(
907 reason.contains("unsupported LUT format"),
908 "reason should mention unsupported format: {reason}"
909 );
910 }
911 }
912
913 #[test]
914 fn builder_lut3d_with_no_extension_should_return_invalid_config() {
915 let result = FilterGraph::builder().lut3d("color_grade_no_ext").build();
916 assert!(
917 matches!(result, Err(FilterError::InvalidConfig { .. })),
918 "expected InvalidConfig for missing extension, got {result:?}"
919 );
920 }
921
922 #[test]
923 fn builder_lut3d_with_nonexistent_cube_file_should_return_invalid_config() {
924 let result = FilterGraph::builder()
925 .lut3d("/nonexistent/path/grade_ab12cd.cube")
926 .build();
927 assert!(
928 matches!(result, Err(FilterError::InvalidConfig { .. })),
929 "expected InvalidConfig for nonexistent file, got {result:?}"
930 );
931 if let Err(FilterError::InvalidConfig { reason }) = result {
932 assert!(
933 reason.contains("LUT file not found"),
934 "reason should mention file not found: {reason}"
935 );
936 }
937 }
938
939 #[test]
940 fn builder_lut3d_with_nonexistent_3dl_file_should_return_invalid_config() {
941 let result = FilterGraph::builder()
942 .lut3d("/nonexistent/path/grade_ab12cd.3dl")
943 .build();
944 assert!(
945 matches!(result, Err(FilterError::InvalidConfig { .. })),
946 "expected InvalidConfig for nonexistent .3dl file, got {result:?}"
947 );
948 }
949
950 #[test]
951 fn filter_step_eq_should_produce_correct_filter_name() {
952 let step = FilterStep::Eq {
953 brightness: 0.0,
954 contrast: 1.0,
955 saturation: 1.0,
956 };
957 assert_eq!(step.filter_name(), "eq");
958 }
959
960 #[test]
961 fn filter_step_eq_should_produce_correct_args() {
962 let step = FilterStep::Eq {
963 brightness: 0.1,
964 contrast: 1.5,
965 saturation: 0.8,
966 };
967 assert_eq!(step.args(), "brightness=0.1:contrast=1.5:saturation=0.8");
968 }
969
970 #[test]
971 fn builder_eq_with_valid_params_should_succeed() {
972 let result = FilterGraph::builder().eq(0.0, 1.0, 1.0).build();
973 assert!(
974 result.is_ok(),
975 "neutral eq params must build successfully, got {result:?}"
976 );
977 }
978
979 #[test]
980 fn builder_eq_with_brightness_too_low_should_return_invalid_config() {
981 let result = FilterGraph::builder().eq(-1.5, 1.0, 1.0).build();
982 assert!(
983 matches!(result, Err(FilterError::InvalidConfig { .. })),
984 "expected InvalidConfig for brightness < -1.0, got {result:?}"
985 );
986 if let Err(FilterError::InvalidConfig { reason }) = result {
987 assert!(
988 reason.contains("brightness"),
989 "reason should mention brightness: {reason}"
990 );
991 }
992 }
993
994 #[test]
995 fn builder_eq_with_brightness_too_high_should_return_invalid_config() {
996 let result = FilterGraph::builder().eq(1.5, 1.0, 1.0).build();
997 assert!(
998 matches!(result, Err(FilterError::InvalidConfig { .. })),
999 "expected InvalidConfig for brightness > 1.0, got {result:?}"
1000 );
1001 }
1002
1003 #[test]
1004 fn builder_eq_with_contrast_out_of_range_should_return_invalid_config() {
1005 let result = FilterGraph::builder().eq(0.0, 4.0, 1.0).build();
1006 assert!(
1007 matches!(result, Err(FilterError::InvalidConfig { .. })),
1008 "expected InvalidConfig for contrast > 3.0, got {result:?}"
1009 );
1010 if let Err(FilterError::InvalidConfig { reason }) = result {
1011 assert!(
1012 reason.contains("contrast"),
1013 "reason should mention contrast: {reason}"
1014 );
1015 }
1016 }
1017
1018 #[test]
1019 fn builder_eq_with_saturation_out_of_range_should_return_invalid_config() {
1020 let result = FilterGraph::builder().eq(0.0, 1.0, -0.5).build();
1021 assert!(
1022 matches!(result, Err(FilterError::InvalidConfig { .. })),
1023 "expected InvalidConfig for saturation < 0.0, got {result:?}"
1024 );
1025 if let Err(FilterError::InvalidConfig { reason }) = result {
1026 assert!(
1027 reason.contains("saturation"),
1028 "reason should mention saturation: {reason}"
1029 );
1030 }
1031 }
1032
1033 #[test]
1034 fn filter_step_curves_should_produce_correct_filter_name() {
1035 let step = FilterStep::Curves {
1036 master: vec![],
1037 r: vec![],
1038 g: vec![],
1039 b: vec![],
1040 };
1041 assert_eq!(step.filter_name(), "curves");
1042 }
1043
1044 #[test]
1045 fn filter_step_curves_should_produce_args_with_all_channels() {
1046 let step = FilterStep::Curves {
1047 master: vec![(0.0, 0.0), (0.5, 0.6), (1.0, 1.0)],
1048 r: vec![(0.0, 0.0), (1.0, 1.0)],
1049 g: vec![],
1050 b: vec![(0.0, 0.0), (1.0, 0.8)],
1051 };
1052 let args = step.args();
1053 assert!(args.contains("master='0/0 0.5/0.6 1/1'"), "args={args}");
1054 assert!(args.contains("r='0/0 1/1'"), "args={args}");
1055 assert!(
1056 !args.contains("g="),
1057 "empty g channel should be omitted: args={args}"
1058 );
1059 assert!(args.contains("b='0/0 1/0.8'"), "args={args}");
1060 }
1061
1062 #[test]
1063 fn filter_step_curves_with_empty_channels_should_produce_empty_args() {
1064 let step = FilterStep::Curves {
1065 master: vec![],
1066 r: vec![],
1067 g: vec![],
1068 b: vec![],
1069 };
1070 assert_eq!(
1071 step.args(),
1072 "",
1073 "all-empty curves should produce empty args string"
1074 );
1075 }
1076
1077 #[test]
1078 fn builder_curves_with_valid_s_curve_should_succeed() {
1079 let result = FilterGraph::builder()
1080 .curves(
1081 vec![
1082 (0.0, 0.0),
1083 (0.25, 0.15),
1084 (0.5, 0.5),
1085 (0.75, 0.85),
1086 (1.0, 1.0),
1087 ],
1088 vec![],
1089 vec![],
1090 vec![],
1091 )
1092 .build();
1093 assert!(
1094 result.is_ok(),
1095 "valid S-curve master must build successfully, got {result:?}"
1096 );
1097 }
1098
1099 #[test]
1100 fn builder_curves_with_out_of_range_point_should_return_invalid_config() {
1101 let result = FilterGraph::builder()
1102 .curves(vec![(0.0, 1.5)], vec![], vec![], vec![])
1103 .build();
1104 assert!(
1105 matches!(result, Err(FilterError::InvalidConfig { .. })),
1106 "expected InvalidConfig for out-of-range point, got {result:?}"
1107 );
1108 if let Err(FilterError::InvalidConfig { reason }) = result {
1109 assert!(
1110 reason.contains("curves") && reason.contains("master"),
1111 "reason should mention curves master: {reason}"
1112 );
1113 }
1114 }
1115
1116 #[test]
1117 fn builder_curves_with_out_of_range_r_channel_should_return_invalid_config() {
1118 let result = FilterGraph::builder()
1119 .curves(vec![], vec![(1.2, 0.5)], vec![], vec![])
1120 .build();
1121 assert!(
1122 matches!(result, Err(FilterError::InvalidConfig { .. })),
1123 "expected InvalidConfig for out-of-range r channel point, got {result:?}"
1124 );
1125 if let Err(FilterError::InvalidConfig { reason }) = result {
1126 assert!(
1127 reason.contains("curves") && reason.contains(" r "),
1128 "reason should mention curves r: {reason}"
1129 );
1130 }
1131 }
1132
1133 #[test]
1134 fn filter_step_white_balance_should_produce_correct_filter_name() {
1135 let step = FilterStep::WhiteBalance {
1136 temperature_k: 6500,
1137 tint: 0.0,
1138 };
1139 assert_eq!(step.filter_name(), "colorchannelmixer");
1140 }
1141
1142 #[test]
1143 fn filter_step_white_balance_6500k_neutral_tint_should_produce_near_unity_args() {
1144 let step = FilterStep::WhiteBalance {
1146 temperature_k: 6500,
1147 tint: 0.0,
1148 };
1149 let args = step.args();
1150 assert!(args.starts_with("rr="), "args must start with rr=: {args}");
1152 assert!(
1153 args.contains("gg=") && args.contains("bb="),
1154 "args must contain gg and bb: {args}"
1155 );
1156 }
1157
1158 #[test]
1159 fn filter_step_white_balance_3200k_should_produce_warm_shift() {
1160 use super::super::super::filter_step::FilterStep as FS;
1162 let step_warm = FS::WhiteBalance {
1164 temperature_k: 3200,
1165 tint: 0.0,
1166 };
1167 let step_cool = FS::WhiteBalance {
1168 temperature_k: 10000,
1169 tint: 0.0,
1170 };
1171 let args_warm = step_warm.args();
1172 let args_cool = step_cool.args();
1173 assert!(
1176 args_warm.contains("rr=") && args_warm.contains("bb="),
1177 "args={args_warm}"
1178 );
1179 assert!(
1180 args_cool.contains("rr=") && args_cool.contains("bb="),
1181 "args={args_cool}"
1182 );
1183 }
1184
1185 #[test]
1186 fn builder_white_balance_with_valid_params_should_succeed() {
1187 let result = FilterGraph::builder().white_balance(6500, 0.0).build();
1188 assert!(
1189 result.is_ok(),
1190 "valid white_balance params must build successfully, got {result:?}"
1191 );
1192 }
1193
1194 #[test]
1195 fn builder_white_balance_with_temperature_too_low_should_return_invalid_config() {
1196 let result = FilterGraph::builder().white_balance(500, 0.0).build();
1197 assert!(
1198 matches!(result, Err(FilterError::InvalidConfig { .. })),
1199 "expected InvalidConfig for temperature_k < 1000, got {result:?}"
1200 );
1201 if let Err(FilterError::InvalidConfig { reason }) = result {
1202 assert!(
1203 reason.contains("temperature_k"),
1204 "reason should mention temperature_k: {reason}"
1205 );
1206 }
1207 }
1208
1209 #[test]
1210 fn builder_white_balance_with_temperature_too_high_should_return_invalid_config() {
1211 let result = FilterGraph::builder().white_balance(50000, 0.0).build();
1212 assert!(
1213 matches!(result, Err(FilterError::InvalidConfig { .. })),
1214 "expected InvalidConfig for temperature_k > 40000, got {result:?}"
1215 );
1216 }
1217
1218 #[test]
1219 fn builder_white_balance_with_tint_out_of_range_should_return_invalid_config() {
1220 let result = FilterGraph::builder().white_balance(6500, 1.5).build();
1221 assert!(
1222 matches!(result, Err(FilterError::InvalidConfig { .. })),
1223 "expected InvalidConfig for tint > 1.0, got {result:?}"
1224 );
1225 if let Err(FilterError::InvalidConfig { reason }) = result {
1226 assert!(
1227 reason.contains("tint"),
1228 "reason should mention tint: {reason}"
1229 );
1230 }
1231 }
1232
1233 #[test]
1234 fn filter_step_hue_should_produce_correct_filter_name() {
1235 let step = FilterStep::Hue { degrees: 90.0 };
1236 assert_eq!(step.filter_name(), "hue");
1237 }
1238
1239 #[test]
1240 fn filter_step_hue_should_produce_correct_args() {
1241 let step = FilterStep::Hue { degrees: 180.0 };
1242 assert_eq!(step.args(), "h=180");
1243 }
1244
1245 #[test]
1246 fn filter_step_hue_zero_should_produce_no_op_args() {
1247 let step = FilterStep::Hue { degrees: 0.0 };
1248 assert_eq!(step.args(), "h=0");
1249 }
1250
1251 #[test]
1252 fn builder_hue_with_valid_degrees_should_succeed() {
1253 let result = FilterGraph::builder().hue(0.0).build();
1254 assert!(
1255 result.is_ok(),
1256 "hue(0.0) must build successfully, got {result:?}"
1257 );
1258 }
1259
1260 #[test]
1261 fn builder_hue_with_degrees_too_high_should_return_invalid_config() {
1262 let result = FilterGraph::builder().hue(400.0).build();
1263 assert!(
1264 matches!(result, Err(FilterError::InvalidConfig { .. })),
1265 "expected InvalidConfig for degrees > 360.0, got {result:?}"
1266 );
1267 if let Err(FilterError::InvalidConfig { reason }) = result {
1268 assert!(
1269 reason.contains("degrees"),
1270 "reason should mention degrees: {reason}"
1271 );
1272 }
1273 }
1274
1275 #[test]
1276 fn builder_hue_with_degrees_too_low_should_return_invalid_config() {
1277 let result = FilterGraph::builder().hue(-400.0).build();
1278 assert!(
1279 matches!(result, Err(FilterError::InvalidConfig { .. })),
1280 "expected InvalidConfig for degrees < -360.0, got {result:?}"
1281 );
1282 }
1283
1284 #[test]
1285 fn filter_step_gamma_should_produce_correct_filter_name() {
1286 let step = FilterStep::Gamma {
1287 r: 1.0,
1288 g: 1.0,
1289 b: 1.0,
1290 };
1291 assert_eq!(step.filter_name(), "eq");
1292 }
1293
1294 #[test]
1295 fn filter_step_gamma_should_produce_correct_args() {
1296 let step = FilterStep::Gamma {
1297 r: 2.2,
1298 g: 2.2,
1299 b: 2.2,
1300 };
1301 assert_eq!(step.args(), "gamma_r=2.2:gamma_g=2.2:gamma_b=2.2");
1302 }
1303
1304 #[test]
1305 fn filter_step_gamma_neutral_should_produce_unity_args() {
1306 let step = FilterStep::Gamma {
1307 r: 1.0,
1308 g: 1.0,
1309 b: 1.0,
1310 };
1311 assert_eq!(step.args(), "gamma_r=1:gamma_g=1:gamma_b=1");
1312 }
1313
1314 #[test]
1315 fn builder_gamma_with_neutral_values_should_succeed() {
1316 let result = FilterGraph::builder().gamma(1.0, 1.0, 1.0).build();
1317 assert!(
1318 result.is_ok(),
1319 "gamma(1.0, 1.0, 1.0) must build successfully, got {result:?}"
1320 );
1321 }
1322
1323 #[test]
1324 fn builder_gamma_with_r_out_of_range_should_return_invalid_config() {
1325 let result = FilterGraph::builder().gamma(0.0, 1.0, 1.0).build();
1326 assert!(
1327 matches!(result, Err(FilterError::InvalidConfig { .. })),
1328 "expected InvalidConfig for r < 0.1, got {result:?}"
1329 );
1330 if let Err(FilterError::InvalidConfig { reason }) = result {
1331 assert!(
1332 reason.contains("gamma") && reason.contains(" r "),
1333 "reason should mention gamma r: {reason}"
1334 );
1335 }
1336 }
1337
1338 #[test]
1339 fn builder_gamma_with_b_out_of_range_should_return_invalid_config() {
1340 let result = FilterGraph::builder().gamma(1.0, 1.0, 11.0).build();
1341 assert!(
1342 matches!(result, Err(FilterError::InvalidConfig { .. })),
1343 "expected InvalidConfig for b > 10.0, got {result:?}"
1344 );
1345 }
1346
1347 #[test]
1348 fn filter_step_three_way_cc_should_produce_correct_filter_name() {
1349 let step = FilterStep::ThreeWayCC {
1350 lift: Rgb::NEUTRAL,
1351 gamma: Rgb::NEUTRAL,
1352 gain: Rgb::NEUTRAL,
1353 };
1354 assert_eq!(step.filter_name(), "curves");
1355 }
1356
1357 #[test]
1358 fn filter_step_three_way_cc_neutral_should_produce_identity_curves() {
1359 let step = FilterStep::ThreeWayCC {
1360 lift: Rgb::NEUTRAL,
1361 gamma: Rgb::NEUTRAL,
1362 gain: Rgb::NEUTRAL,
1363 };
1364 let args = step.args();
1365 assert!(
1367 args.contains("r='0/0 0.5/0.5 1/1'"),
1368 "neutral r channel must be identity: {args}"
1369 );
1370 assert!(
1371 args.contains("g='0/0 0.5/0.5 1/1'"),
1372 "neutral g channel must be identity: {args}"
1373 );
1374 assert!(
1375 args.contains("b='0/0 0.5/0.5 1/1'"),
1376 "neutral b channel must be identity: {args}"
1377 );
1378 }
1379
1380 #[test]
1381 fn builder_three_way_cc_with_neutral_values_should_succeed() {
1382 let result = FilterGraph::builder()
1383 .three_way_cc(Rgb::NEUTRAL, Rgb::NEUTRAL, Rgb::NEUTRAL)
1384 .build();
1385 assert!(
1386 result.is_ok(),
1387 "neutral three_way_cc must build successfully, got {result:?}"
1388 );
1389 }
1390
1391 #[test]
1392 fn builder_three_way_cc_with_gamma_zero_should_return_invalid_config() {
1393 let result = FilterGraph::builder()
1394 .three_way_cc(
1395 Rgb::NEUTRAL,
1396 Rgb {
1397 r: 0.0,
1398 g: 1.0,
1399 b: 1.0,
1400 },
1401 Rgb::NEUTRAL,
1402 )
1403 .build();
1404 assert!(
1405 matches!(result, Err(FilterError::InvalidConfig { .. })),
1406 "expected InvalidConfig for gamma.r = 0.0, got {result:?}"
1407 );
1408 if let Err(FilterError::InvalidConfig { reason }) = result {
1409 assert!(
1410 reason.contains("gamma.r"),
1411 "reason should mention gamma.r: {reason}"
1412 );
1413 }
1414 }
1415
1416 #[test]
1417 fn builder_three_way_cc_with_negative_gamma_should_return_invalid_config() {
1418 let result = FilterGraph::builder()
1419 .three_way_cc(
1420 Rgb::NEUTRAL,
1421 Rgb {
1422 r: 1.0,
1423 g: -0.5,
1424 b: 1.0,
1425 },
1426 Rgb::NEUTRAL,
1427 )
1428 .build();
1429 assert!(
1430 matches!(result, Err(FilterError::InvalidConfig { .. })),
1431 "expected InvalidConfig for gamma.g < 0.0, got {result:?}"
1432 );
1433 if let Err(FilterError::InvalidConfig { reason }) = result {
1434 assert!(
1435 reason.contains("gamma.g"),
1436 "reason should mention gamma.g: {reason}"
1437 );
1438 }
1439 }
1440
1441 #[test]
1442 fn filter_step_vignette_should_produce_correct_filter_name() {
1443 let step = FilterStep::Vignette {
1444 angle: 0.628,
1445 x0: 0.0,
1446 y0: 0.0,
1447 };
1448 assert_eq!(step.filter_name(), "vignette");
1449 }
1450
1451 #[test]
1452 fn filter_step_vignette_zero_centre_should_use_w2_h2_defaults() {
1453 let step = FilterStep::Vignette {
1454 angle: 0.628,
1455 x0: 0.0,
1456 y0: 0.0,
1457 };
1458 let args = step.args();
1459 assert!(args.contains("x0=w/2"), "x0=0.0 should map to w/2: {args}");
1460 assert!(args.contains("y0=h/2"), "y0=0.0 should map to h/2: {args}");
1461 assert!(
1462 args.contains("angle=0.628"),
1463 "args must contain angle: {args}"
1464 );
1465 }
1466
1467 #[test]
1468 fn filter_step_vignette_custom_centre_should_produce_numeric_coords() {
1469 let step = FilterStep::Vignette {
1470 angle: 0.5,
1471 x0: 320.0,
1472 y0: 240.0,
1473 };
1474 let args = step.args();
1475 assert!(args.contains("x0=320"), "custom x0 should appear: {args}");
1476 assert!(args.contains("y0=240"), "custom y0 should appear: {args}");
1477 }
1478
1479 #[test]
1480 fn builder_vignette_with_valid_angle_should_succeed() {
1481 let result = FilterGraph::builder()
1482 .vignette(std::f32::consts::PI / 5.0, 0.0, 0.0)
1483 .build();
1484 assert!(
1485 result.is_ok(),
1486 "default vignette angle must build successfully, got {result:?}"
1487 );
1488 }
1489
1490 #[test]
1491 fn builder_vignette_with_angle_too_large_should_return_invalid_config() {
1492 let result = FilterGraph::builder().vignette(2.0, 0.0, 0.0).build();
1493 assert!(
1494 matches!(result, Err(FilterError::InvalidConfig { .. })),
1495 "expected InvalidConfig for angle > π/2, got {result:?}"
1496 );
1497 if let Err(FilterError::InvalidConfig { reason }) = result {
1498 assert!(
1499 reason.contains("angle"),
1500 "reason should mention angle: {reason}"
1501 );
1502 }
1503 }
1504
1505 #[test]
1506 fn builder_vignette_with_negative_angle_should_return_invalid_config() {
1507 let result = FilterGraph::builder().vignette(-0.1, 0.0, 0.0).build();
1508 assert!(
1509 matches!(result, Err(FilterError::InvalidConfig { .. })),
1510 "expected InvalidConfig for angle < 0.0, got {result:?}"
1511 );
1512 }
1513
1514 #[test]
1515 fn builder_crop_with_zero_width_should_return_invalid_config() {
1516 let result = FilterGraph::builder().crop(0, 0, 0, 100).build();
1517 assert!(
1518 matches!(result, Err(FilterError::InvalidConfig { .. })),
1519 "expected InvalidConfig for width=0, got {result:?}"
1520 );
1521 if let Err(FilterError::InvalidConfig { reason }) = result {
1522 assert!(
1523 reason.contains("crop width and height must be > 0"),
1524 "reason should mention crop dimensions: {reason}"
1525 );
1526 }
1527 }
1528
1529 #[test]
1530 fn builder_crop_with_zero_height_should_return_invalid_config() {
1531 let result = FilterGraph::builder().crop(0, 0, 100, 0).build();
1532 assert!(
1533 matches!(result, Err(FilterError::InvalidConfig { .. })),
1534 "expected InvalidConfig for height=0, got {result:?}"
1535 );
1536 }
1537
1538 #[test]
1539 fn builder_crop_with_valid_dimensions_should_succeed() {
1540 let result = FilterGraph::builder().crop(0, 0, 64, 64).build();
1541 assert!(
1542 result.is_ok(),
1543 "crop with valid dimensions must build successfully, got {result:?}"
1544 );
1545 }
1546
1547 #[test]
1548 fn filter_step_hflip_should_produce_correct_filter_name_and_empty_args() {
1549 let step = FilterStep::HFlip;
1550 assert_eq!(step.filter_name(), "hflip");
1551 assert_eq!(step.args(), "");
1552 }
1553
1554 #[test]
1555 fn filter_step_vflip_should_produce_correct_filter_name_and_empty_args() {
1556 let step = FilterStep::VFlip;
1557 assert_eq!(step.filter_name(), "vflip");
1558 assert_eq!(step.args(), "");
1559 }
1560
1561 #[test]
1562 fn builder_hflip_should_succeed() {
1563 let result = FilterGraph::builder().hflip().build();
1564 assert!(
1565 result.is_ok(),
1566 "hflip must build successfully, got {result:?}"
1567 );
1568 }
1569
1570 #[test]
1571 fn builder_vflip_should_succeed() {
1572 let result = FilterGraph::builder().vflip().build();
1573 assert!(
1574 result.is_ok(),
1575 "vflip must build successfully, got {result:?}"
1576 );
1577 }
1578
1579 #[test]
1580 fn builder_hflip_twice_should_succeed() {
1581 let result = FilterGraph::builder().hflip().hflip().build();
1582 assert!(
1583 result.is_ok(),
1584 "double hflip (round-trip) must build successfully, got {result:?}"
1585 );
1586 }
1587
1588 #[test]
1589 fn filter_step_pad_should_produce_correct_filter_name() {
1590 let step = FilterStep::Pad {
1591 width: 1920,
1592 height: 1080,
1593 x: -1,
1594 y: -1,
1595 color: "black".to_owned(),
1596 };
1597 assert_eq!(step.filter_name(), "pad");
1598 }
1599
1600 #[test]
1601 fn filter_step_pad_negative_xy_should_produce_centred_args() {
1602 let step = FilterStep::Pad {
1603 width: 1920,
1604 height: 1080,
1605 x: -1,
1606 y: -1,
1607 color: "black".to_owned(),
1608 };
1609 assert_eq!(
1610 step.args(),
1611 "width=1920:height=1080:x=(ow-iw)/2:y=(oh-ih)/2:color=black"
1612 );
1613 }
1614
1615 #[test]
1616 fn filter_step_pad_explicit_xy_should_produce_numeric_args() {
1617 let step = FilterStep::Pad {
1618 width: 1920,
1619 height: 1080,
1620 x: 320,
1621 y: 180,
1622 color: "0x000000".to_owned(),
1623 };
1624 assert_eq!(
1625 step.args(),
1626 "width=1920:height=1080:x=320:y=180:color=0x000000"
1627 );
1628 }
1629
1630 #[test]
1631 fn filter_step_pad_zero_xy_should_produce_zero_offset_args() {
1632 let step = FilterStep::Pad {
1633 width: 1280,
1634 height: 720,
1635 x: 0,
1636 y: 0,
1637 color: "black".to_owned(),
1638 };
1639 assert_eq!(step.args(), "width=1280:height=720:x=0:y=0:color=black");
1640 }
1641
1642 #[test]
1643 fn builder_pad_with_valid_params_should_succeed() {
1644 let result = FilterGraph::builder()
1645 .pad(1920, 1080, -1, -1, "black")
1646 .build();
1647 assert!(
1648 result.is_ok(),
1649 "pad with valid params must build successfully, got {result:?}"
1650 );
1651 }
1652
1653 #[test]
1654 fn builder_pad_with_zero_width_should_return_invalid_config() {
1655 let result = FilterGraph::builder().pad(0, 1080, -1, -1, "black").build();
1656 assert!(
1657 matches!(result, Err(FilterError::InvalidConfig { .. })),
1658 "expected InvalidConfig for width=0, got {result:?}"
1659 );
1660 if let Err(FilterError::InvalidConfig { reason }) = result {
1661 assert!(
1662 reason.contains("pad width and height must be > 0"),
1663 "reason should mention pad dimensions: {reason}"
1664 );
1665 }
1666 }
1667
1668 #[test]
1669 fn builder_pad_with_zero_height_should_return_invalid_config() {
1670 let result = FilterGraph::builder().pad(1920, 0, -1, -1, "black").build();
1671 assert!(
1672 matches!(result, Err(FilterError::InvalidConfig { .. })),
1673 "expected InvalidConfig for height=0, got {result:?}"
1674 );
1675 }
1676
1677 #[test]
1678 fn filter_step_fit_to_aspect_should_produce_correct_filter_name() {
1679 let step = FilterStep::FitToAspect {
1680 width: 1920,
1681 height: 1080,
1682 color: "black".to_owned(),
1683 };
1684 assert_eq!(step.filter_name(), "scale");
1685 }
1686
1687 #[test]
1688 fn filter_step_fit_to_aspect_should_produce_scale_args_with_force_original_aspect_ratio() {
1689 let step = FilterStep::FitToAspect {
1690 width: 1920,
1691 height: 1080,
1692 color: "black".to_owned(),
1693 };
1694 let args = step.args();
1695 assert!(
1696 args.contains("w=1920") && args.contains("h=1080"),
1697 "args must contain target dimensions: {args}"
1698 );
1699 assert!(
1700 args.contains("force_original_aspect_ratio=decrease"),
1701 "args must request aspect-ratio-preserving scale: {args}"
1702 );
1703 }
1704
1705 #[test]
1706 fn builder_fit_to_aspect_with_valid_params_should_succeed() {
1707 let result = FilterGraph::builder()
1708 .fit_to_aspect(1920, 1080, "black")
1709 .build();
1710 assert!(
1711 result.is_ok(),
1712 "fit_to_aspect with valid params must build successfully, got {result:?}"
1713 );
1714 }
1715
1716 #[test]
1717 fn builder_fit_to_aspect_with_zero_width_should_return_invalid_config() {
1718 let result = FilterGraph::builder()
1719 .fit_to_aspect(0, 1080, "black")
1720 .build();
1721 assert!(
1722 matches!(result, Err(FilterError::InvalidConfig { .. })),
1723 "expected InvalidConfig for width=0, got {result:?}"
1724 );
1725 if let Err(FilterError::InvalidConfig { reason }) = result {
1726 assert!(
1727 reason.contains("fit_to_aspect width and height must be > 0"),
1728 "reason should mention fit_to_aspect dimensions: {reason}"
1729 );
1730 }
1731 }
1732
1733 #[test]
1734 fn builder_fit_to_aspect_with_zero_height_should_return_invalid_config() {
1735 let result = FilterGraph::builder()
1736 .fit_to_aspect(1920, 0, "black")
1737 .build();
1738 assert!(
1739 matches!(result, Err(FilterError::InvalidConfig { .. })),
1740 "expected InvalidConfig for height=0, got {result:?}"
1741 );
1742 }
1743
1744 #[test]
1745 fn filter_step_gblur_should_produce_correct_filter_name() {
1746 let step = FilterStep::GBlur { sigma: 5.0 };
1747 assert_eq!(step.filter_name(), "gblur");
1748 }
1749
1750 #[test]
1751 fn filter_step_gblur_should_produce_correct_args() {
1752 let step = FilterStep::GBlur { sigma: 5.0 };
1753 assert_eq!(step.args(), "sigma=5");
1754 }
1755
1756 #[test]
1757 fn filter_step_gblur_small_sigma_should_produce_correct_args() {
1758 let step = FilterStep::GBlur { sigma: 0.1 };
1759 assert_eq!(step.args(), "sigma=0.1");
1760 }
1761
1762 #[test]
1763 fn builder_gblur_with_valid_sigma_should_succeed() {
1764 let result = FilterGraph::builder().gblur(5.0).build();
1765 assert!(
1766 result.is_ok(),
1767 "gblur(5.0) must build successfully, got {result:?}"
1768 );
1769 }
1770
1771 #[test]
1772 fn builder_gblur_with_zero_sigma_should_succeed() {
1773 let result = FilterGraph::builder().gblur(0.0).build();
1774 assert!(
1775 result.is_ok(),
1776 "gblur(0.0) must build successfully (no-op), got {result:?}"
1777 );
1778 }
1779
1780 #[test]
1781 fn builder_gblur_with_negative_sigma_should_return_invalid_config() {
1782 let result = FilterGraph::builder().gblur(-1.0).build();
1783 assert!(
1784 matches!(result, Err(FilterError::InvalidConfig { .. })),
1785 "expected InvalidConfig for sigma < 0.0, got {result:?}"
1786 );
1787 if let Err(FilterError::InvalidConfig { reason }) = result {
1788 assert!(
1789 reason.contains("sigma"),
1790 "reason should mention sigma: {reason}"
1791 );
1792 }
1793 }
1794
1795 #[test]
1796 fn filter_step_unsharp_should_produce_correct_filter_name() {
1797 let step = FilterStep::Unsharp {
1798 luma_strength: 1.0,
1799 chroma_strength: 0.0,
1800 };
1801 assert_eq!(step.filter_name(), "unsharp");
1802 }
1803
1804 #[test]
1805 fn filter_step_unsharp_should_produce_correct_args() {
1806 let step = FilterStep::Unsharp {
1807 luma_strength: 1.0,
1808 chroma_strength: 0.5,
1809 };
1810 let args = step.args();
1811 assert!(
1812 args.contains("luma_amount=1") && args.contains("chroma_amount=0.5"),
1813 "args must contain luma and chroma amounts: {args}"
1814 );
1815 assert!(
1816 args.contains("luma_msize_x=5") && args.contains("luma_msize_y=5"),
1817 "args must contain luma matrix size: {args}"
1818 );
1819 assert!(
1820 args.contains("chroma_msize_x=5") && args.contains("chroma_msize_y=5"),
1821 "args must contain chroma matrix size: {args}"
1822 );
1823 }
1824
1825 #[test]
1826 fn builder_unsharp_with_valid_params_should_succeed() {
1827 let result = FilterGraph::builder().unsharp(1.0, 0.0).build();
1828 assert!(
1829 result.is_ok(),
1830 "unsharp(1.0, 0.0) must build successfully, got {result:?}"
1831 );
1832 }
1833
1834 #[test]
1835 fn builder_unsharp_with_negative_luma_should_succeed() {
1836 let result = FilterGraph::builder().unsharp(-1.0, 0.0).build();
1837 assert!(
1838 result.is_ok(),
1839 "unsharp(-1.0, 0.0) (blur) must build successfully, got {result:?}"
1840 );
1841 }
1842
1843 #[test]
1844 fn builder_unsharp_with_luma_too_high_should_return_invalid_config() {
1845 let result = FilterGraph::builder().unsharp(2.0, 0.0).build();
1846 assert!(
1847 matches!(result, Err(FilterError::InvalidConfig { .. })),
1848 "expected InvalidConfig for luma_strength > 1.5, got {result:?}"
1849 );
1850 if let Err(FilterError::InvalidConfig { reason }) = result {
1851 assert!(
1852 reason.contains("luma_strength"),
1853 "reason should mention luma_strength: {reason}"
1854 );
1855 }
1856 }
1857
1858 #[test]
1859 fn builder_unsharp_with_luma_too_low_should_return_invalid_config() {
1860 let result = FilterGraph::builder().unsharp(-2.0, 0.0).build();
1861 assert!(
1862 matches!(result, Err(FilterError::InvalidConfig { .. })),
1863 "expected InvalidConfig for luma_strength < -1.5, got {result:?}"
1864 );
1865 }
1866
1867 #[test]
1868 fn builder_unsharp_with_chroma_too_high_should_return_invalid_config() {
1869 let result = FilterGraph::builder().unsharp(0.0, 2.0).build();
1870 assert!(
1871 matches!(result, Err(FilterError::InvalidConfig { .. })),
1872 "expected InvalidConfig for chroma_strength > 1.5, got {result:?}"
1873 );
1874 if let Err(FilterError::InvalidConfig { reason }) = result {
1875 assert!(
1876 reason.contains("chroma_strength"),
1877 "reason should mention chroma_strength: {reason}"
1878 );
1879 }
1880 }
1881
1882 #[test]
1883 fn filter_step_hqdn3d_should_produce_correct_filter_name() {
1884 let step = FilterStep::Hqdn3d {
1885 luma_spatial: 4.0,
1886 chroma_spatial: 3.0,
1887 luma_tmp: 6.0,
1888 chroma_tmp: 4.5,
1889 };
1890 assert_eq!(step.filter_name(), "hqdn3d");
1891 }
1892
1893 #[test]
1894 fn filter_step_hqdn3d_should_produce_correct_args() {
1895 let step = FilterStep::Hqdn3d {
1896 luma_spatial: 4.0,
1897 chroma_spatial: 3.0,
1898 luma_tmp: 6.0,
1899 chroma_tmp: 4.5,
1900 };
1901 assert_eq!(step.args(), "4:3:6:4.5");
1902 }
1903
1904 #[test]
1905 fn builder_hqdn3d_with_valid_params_should_succeed() {
1906 let result = FilterGraph::builder().hqdn3d(4.0, 3.0, 6.0, 4.5).build();
1907 assert!(
1908 result.is_ok(),
1909 "hqdn3d(4.0, 3.0, 6.0, 4.5) must build successfully, got {result:?}"
1910 );
1911 }
1912
1913 #[test]
1914 fn builder_hqdn3d_with_zero_params_should_succeed() {
1915 let result = FilterGraph::builder().hqdn3d(0.0, 0.0, 0.0, 0.0).build();
1916 assert!(
1917 result.is_ok(),
1918 "hqdn3d(0.0, 0.0, 0.0, 0.0) must build successfully, got {result:?}"
1919 );
1920 }
1921
1922 #[test]
1923 fn builder_hqdn3d_with_negative_luma_spatial_should_return_invalid_config() {
1924 let result = FilterGraph::builder().hqdn3d(-1.0, 3.0, 6.0, 4.5).build();
1925 assert!(
1926 matches!(result, Err(FilterError::InvalidConfig { .. })),
1927 "expected InvalidConfig for negative luma_spatial, got {result:?}"
1928 );
1929 if let Err(FilterError::InvalidConfig { reason }) = result {
1930 assert!(
1931 reason.contains("luma_spatial"),
1932 "reason should mention luma_spatial: {reason}"
1933 );
1934 }
1935 }
1936
1937 #[test]
1938 fn builder_hqdn3d_with_negative_chroma_spatial_should_return_invalid_config() {
1939 let result = FilterGraph::builder().hqdn3d(4.0, -1.0, 6.0, 4.5).build();
1940 assert!(
1941 matches!(result, Err(FilterError::InvalidConfig { .. })),
1942 "expected InvalidConfig for negative chroma_spatial, got {result:?}"
1943 );
1944 }
1945
1946 #[test]
1947 fn builder_hqdn3d_with_negative_luma_tmp_should_return_invalid_config() {
1948 let result = FilterGraph::builder().hqdn3d(4.0, 3.0, -1.0, 4.5).build();
1949 assert!(
1950 matches!(result, Err(FilterError::InvalidConfig { .. })),
1951 "expected InvalidConfig for negative luma_tmp, got {result:?}"
1952 );
1953 }
1954
1955 #[test]
1956 fn builder_hqdn3d_with_negative_chroma_tmp_should_return_invalid_config() {
1957 let result = FilterGraph::builder().hqdn3d(4.0, 3.0, 6.0, -1.0).build();
1958 assert!(
1959 matches!(result, Err(FilterError::InvalidConfig { .. })),
1960 "expected InvalidConfig for negative chroma_tmp, got {result:?}"
1961 );
1962 }
1963
1964 #[test]
1965 fn filter_step_nlmeans_should_produce_correct_filter_name() {
1966 let step = FilterStep::Nlmeans { strength: 8.0 };
1967 assert_eq!(step.filter_name(), "nlmeans");
1968 }
1969
1970 #[test]
1971 fn filter_step_nlmeans_should_produce_correct_args() {
1972 let step = FilterStep::Nlmeans { strength: 8.0 };
1973 assert_eq!(step.args(), "s=8");
1974 }
1975
1976 #[test]
1977 fn builder_nlmeans_with_valid_strength_should_succeed() {
1978 let result = FilterGraph::builder().nlmeans(8.0).build();
1979 assert!(
1980 result.is_ok(),
1981 "nlmeans(8.0) must build successfully, got {result:?}"
1982 );
1983 }
1984
1985 #[test]
1986 fn builder_nlmeans_with_min_strength_should_succeed() {
1987 let result = FilterGraph::builder().nlmeans(1.0).build();
1988 assert!(
1989 result.is_ok(),
1990 "nlmeans(1.0) must build successfully, got {result:?}"
1991 );
1992 }
1993
1994 #[test]
1995 fn builder_nlmeans_with_max_strength_should_succeed() {
1996 let result = FilterGraph::builder().nlmeans(30.0).build();
1997 assert!(
1998 result.is_ok(),
1999 "nlmeans(30.0) must build successfully, got {result:?}"
2000 );
2001 }
2002
2003 #[test]
2004 fn builder_nlmeans_with_strength_too_low_should_return_invalid_config() {
2005 let result = FilterGraph::builder().nlmeans(0.5).build();
2006 assert!(
2007 matches!(result, Err(FilterError::InvalidConfig { .. })),
2008 "expected InvalidConfig for strength < 1.0, got {result:?}"
2009 );
2010 if let Err(FilterError::InvalidConfig { reason }) = result {
2011 assert!(
2012 reason.contains("strength"),
2013 "reason should mention strength: {reason}"
2014 );
2015 }
2016 }
2017
2018 #[test]
2019 fn builder_nlmeans_with_strength_too_high_should_return_invalid_config() {
2020 let result = FilterGraph::builder().nlmeans(31.0).build();
2021 assert!(
2022 matches!(result, Err(FilterError::InvalidConfig { .. })),
2023 "expected InvalidConfig for strength > 30.0, got {result:?}"
2024 );
2025 if let Err(FilterError::InvalidConfig { reason }) = result {
2026 assert!(
2027 reason.contains("strength"),
2028 "reason should mention strength: {reason}"
2029 );
2030 }
2031 }
2032
2033 #[test]
2034 fn yadif_mode_variants_should_have_correct_discriminants() {
2035 assert_eq!(YadifMode::Frame as i32, 0);
2036 assert_eq!(YadifMode::Field as i32, 1);
2037 assert_eq!(YadifMode::FrameNospatial as i32, 2);
2038 assert_eq!(YadifMode::FieldNospatial as i32, 3);
2039 }
2040
2041 #[test]
2042 fn filter_step_yadif_should_produce_correct_filter_name() {
2043 let step = FilterStep::Yadif {
2044 mode: YadifMode::Frame,
2045 };
2046 assert_eq!(step.filter_name(), "yadif");
2047 }
2048
2049 #[test]
2050 fn filter_step_yadif_frame_should_produce_mode_0_args() {
2051 let step = FilterStep::Yadif {
2052 mode: YadifMode::Frame,
2053 };
2054 assert_eq!(step.args(), "mode=0");
2055 }
2056
2057 #[test]
2058 fn filter_step_yadif_field_should_produce_mode_1_args() {
2059 let step = FilterStep::Yadif {
2060 mode: YadifMode::Field,
2061 };
2062 assert_eq!(step.args(), "mode=1");
2063 }
2064
2065 #[test]
2066 fn filter_step_yadif_frame_nospatial_should_produce_mode_2_args() {
2067 let step = FilterStep::Yadif {
2068 mode: YadifMode::FrameNospatial,
2069 };
2070 assert_eq!(step.args(), "mode=2");
2071 }
2072
2073 #[test]
2074 fn filter_step_yadif_field_nospatial_should_produce_mode_3_args() {
2075 let step = FilterStep::Yadif {
2076 mode: YadifMode::FieldNospatial,
2077 };
2078 assert_eq!(step.args(), "mode=3");
2079 }
2080
2081 #[test]
2082 fn builder_yadif_with_frame_mode_should_succeed() {
2083 let result = FilterGraph::builder().yadif(YadifMode::Frame).build();
2084 assert!(
2085 result.is_ok(),
2086 "yadif(Frame) must build successfully, got {result:?}"
2087 );
2088 }
2089
2090 #[test]
2091 fn builder_yadif_with_all_modes_should_succeed() {
2092 for mode in [
2093 YadifMode::Frame,
2094 YadifMode::Field,
2095 YadifMode::FrameNospatial,
2096 YadifMode::FieldNospatial,
2097 ] {
2098 let result = FilterGraph::builder().yadif(mode).build();
2099 assert!(
2100 result.is_ok(),
2101 "yadif({mode:?}) must build successfully, got {result:?}"
2102 );
2103 }
2104 }
2105
2106 #[test]
2107 fn xfade_transition_dissolve_should_produce_correct_str() {
2108 assert_eq!(XfadeTransition::Dissolve.as_str(), "dissolve");
2109 }
2110
2111 #[test]
2112 fn xfade_transition_all_variants_should_produce_unique_strings() {
2113 let variants = [
2114 (XfadeTransition::Dissolve, "dissolve"),
2115 (XfadeTransition::Fade, "fade"),
2116 (XfadeTransition::WipeLeft, "wipeleft"),
2117 (XfadeTransition::WipeRight, "wiperight"),
2118 (XfadeTransition::WipeUp, "wipeup"),
2119 (XfadeTransition::WipeDown, "wipedown"),
2120 (XfadeTransition::SlideLeft, "slideleft"),
2121 (XfadeTransition::SlideRight, "slideright"),
2122 (XfadeTransition::SlideUp, "slideup"),
2123 (XfadeTransition::SlideDown, "slidedown"),
2124 (XfadeTransition::CircleOpen, "circleopen"),
2125 (XfadeTransition::CircleClose, "circleclose"),
2126 (XfadeTransition::FadeGrays, "fadegrays"),
2127 (XfadeTransition::Pixelize, "pixelize"),
2128 ];
2129 for (variant, expected) in variants {
2130 assert_eq!(
2131 variant.as_str(),
2132 expected,
2133 "XfadeTransition::{variant:?} should produce \"{expected}\""
2134 );
2135 }
2136 }
2137
2138 #[test]
2139 fn filter_step_xfade_should_produce_correct_filter_name() {
2140 let step = FilterStep::XFade {
2141 transition: XfadeTransition::Dissolve,
2142 duration: 1.0,
2143 offset: 4.0,
2144 };
2145 assert_eq!(step.filter_name(), "xfade");
2146 }
2147
2148 #[test]
2149 fn filter_step_xfade_should_produce_correct_args() {
2150 let step = FilterStep::XFade {
2151 transition: XfadeTransition::Dissolve,
2152 duration: 1.0,
2153 offset: 4.0,
2154 };
2155 assert_eq!(step.args(), "transition=dissolve:duration=1:offset=4");
2156 }
2157
2158 #[test]
2159 fn filter_step_xfade_wipe_right_should_produce_correct_args() {
2160 let step = FilterStep::XFade {
2161 transition: XfadeTransition::WipeRight,
2162 duration: 0.5,
2163 offset: 9.5,
2164 };
2165 assert_eq!(step.args(), "transition=wiperight:duration=0.5:offset=9.5");
2166 }
2167
2168 #[test]
2169 fn builder_xfade_with_valid_params_should_succeed() {
2170 let result = FilterGraph::builder()
2171 .xfade(XfadeTransition::Dissolve, 1.0, 4.0)
2172 .build();
2173 assert!(
2174 result.is_ok(),
2175 "xfade(Dissolve, 1.0, 4.0) must build successfully, got {result:?}"
2176 );
2177 }
2178
2179 #[test]
2180 fn builder_xfade_with_zero_duration_should_return_invalid_config() {
2181 let result = FilterGraph::builder()
2182 .xfade(XfadeTransition::Dissolve, 0.0, 4.0)
2183 .build();
2184 assert!(
2185 matches!(result, Err(FilterError::InvalidConfig { .. })),
2186 "expected InvalidConfig for zero duration, got {result:?}"
2187 );
2188 if let Err(FilterError::InvalidConfig { reason }) = result {
2189 assert!(
2190 reason.contains("duration"),
2191 "reason should mention duration: {reason}"
2192 );
2193 }
2194 }
2195
2196 #[test]
2197 fn builder_xfade_with_negative_duration_should_return_invalid_config() {
2198 let result = FilterGraph::builder()
2199 .xfade(XfadeTransition::Fade, -1.0, 0.0)
2200 .build();
2201 assert!(
2202 matches!(result, Err(FilterError::InvalidConfig { .. })),
2203 "expected InvalidConfig for negative duration, got {result:?}"
2204 );
2205 }
2206
2207 fn make_drawtext_opts() -> DrawTextOptions {
2208 DrawTextOptions {
2209 text: "Hello".to_string(),
2210 x: "10".to_string(),
2211 y: "10".to_string(),
2212 font_size: 24,
2213 font_color: "white".to_string(),
2214 font_file: None,
2215 opacity: 1.0,
2216 box_color: None,
2217 box_border_width: 0,
2218 }
2219 }
2220
2221 #[test]
2222 fn filter_step_drawtext_should_produce_correct_filter_name() {
2223 let step = FilterStep::DrawText {
2224 opts: make_drawtext_opts(),
2225 };
2226 assert_eq!(step.filter_name(), "drawtext");
2227 }
2228
2229 #[test]
2230 fn filter_step_drawtext_should_produce_correct_args_without_box() {
2231 let step = FilterStep::DrawText {
2232 opts: make_drawtext_opts(),
2233 };
2234 let args = step.args();
2235 assert!(
2236 args.contains("text='Hello'"),
2237 "args must contain text: {args}"
2238 );
2239 assert!(args.contains("x=10"), "args must contain x: {args}");
2240 assert!(args.contains("y=10"), "args must contain y: {args}");
2241 assert!(
2242 args.contains("fontsize=24"),
2243 "args must contain fontsize: {args}"
2244 );
2245 assert!(
2246 args.contains("fontcolor=white@1.00"),
2247 "args must contain fontcolor with opacity: {args}"
2248 );
2249 assert!(
2250 !args.contains("box=1"),
2251 "args must not contain box when box_color is None: {args}"
2252 );
2253 }
2254
2255 #[test]
2256 fn filter_step_drawtext_with_box_should_include_box_args() {
2257 let opts = DrawTextOptions {
2258 box_color: Some("black@0.5".to_string()),
2259 box_border_width: 5,
2260 ..make_drawtext_opts()
2261 };
2262 let step = FilterStep::DrawText { opts };
2263 let args = step.args();
2264 assert!(args.contains("box=1"), "args must contain box=1: {args}");
2265 assert!(
2266 args.contains("boxcolor=black@0.5"),
2267 "args must contain boxcolor: {args}"
2268 );
2269 assert!(
2270 args.contains("boxborderw=5"),
2271 "args must contain boxborderw: {args}"
2272 );
2273 }
2274
2275 #[test]
2276 fn filter_step_drawtext_with_font_file_should_include_fontfile_arg() {
2277 let opts = DrawTextOptions {
2278 font_file: Some("/usr/share/fonts/arial.ttf".to_string()),
2279 ..make_drawtext_opts()
2280 };
2281 let step = FilterStep::DrawText { opts };
2282 let args = step.args();
2283 assert!(
2284 args.contains("fontfile=/usr/share/fonts/arial.ttf"),
2285 "args must contain fontfile: {args}"
2286 );
2287 }
2288
2289 #[test]
2290 fn filter_step_drawtext_should_escape_colon_in_text() {
2291 let opts = DrawTextOptions {
2292 text: "Time: 12:00".to_string(),
2293 ..make_drawtext_opts()
2294 };
2295 let step = FilterStep::DrawText { opts };
2296 let args = step.args();
2297 assert!(
2298 args.contains("Time\\: 12\\:00"),
2299 "colons in text must be escaped: {args}"
2300 );
2301 }
2302
2303 #[test]
2304 fn filter_step_drawtext_should_escape_backslash_in_text() {
2305 let opts = DrawTextOptions {
2306 text: "path\\file".to_string(),
2307 ..make_drawtext_opts()
2308 };
2309 let step = FilterStep::DrawText { opts };
2310 let args = step.args();
2311 assert!(
2312 args.contains("path\\\\file"),
2313 "backslash in text must be escaped: {args}"
2314 );
2315 }
2316
2317 #[test]
2318 fn builder_drawtext_with_valid_opts_should_succeed() {
2319 let result = FilterGraph::builder()
2320 .drawtext(make_drawtext_opts())
2321 .build();
2322 assert!(
2323 result.is_ok(),
2324 "drawtext with valid opts must build successfully, got {result:?}"
2325 );
2326 }
2327
2328 #[test]
2329 fn builder_drawtext_with_empty_text_should_return_invalid_config() {
2330 let opts = DrawTextOptions {
2331 text: String::new(),
2332 ..make_drawtext_opts()
2333 };
2334 let result = FilterGraph::builder().drawtext(opts).build();
2335 assert!(
2336 matches!(result, Err(FilterError::InvalidConfig { .. })),
2337 "expected InvalidConfig for empty text, got {result:?}"
2338 );
2339 if let Err(FilterError::InvalidConfig { reason }) = result {
2340 assert!(
2341 reason.contains("text"),
2342 "reason should mention text: {reason}"
2343 );
2344 }
2345 }
2346
2347 #[test]
2348 fn builder_drawtext_with_opacity_too_high_should_return_invalid_config() {
2349 let opts = DrawTextOptions {
2350 opacity: 1.5,
2351 ..make_drawtext_opts()
2352 };
2353 let result = FilterGraph::builder().drawtext(opts).build();
2354 assert!(
2355 matches!(result, Err(FilterError::InvalidConfig { .. })),
2356 "expected InvalidConfig for opacity > 1.0, got {result:?}"
2357 );
2358 }
2359
2360 #[test]
2361 fn builder_drawtext_with_negative_opacity_should_return_invalid_config() {
2362 let opts = DrawTextOptions {
2363 opacity: -0.1,
2364 ..make_drawtext_opts()
2365 };
2366 let result = FilterGraph::builder().drawtext(opts).build();
2367 assert!(
2368 matches!(result, Err(FilterError::InvalidConfig { .. })),
2369 "expected InvalidConfig for opacity < 0.0, got {result:?}"
2370 );
2371 }
2372
2373 #[test]
2374 fn filter_step_subtitles_srt_should_produce_correct_filter_name() {
2375 let step = FilterStep::SubtitlesSrt {
2376 path: "subs.srt".to_owned(),
2377 };
2378 assert_eq!(step.filter_name(), "subtitles");
2379 }
2380
2381 #[test]
2382 fn filter_step_subtitles_srt_should_produce_correct_args() {
2383 let step = FilterStep::SubtitlesSrt {
2384 path: "subs.srt".to_owned(),
2385 };
2386 assert_eq!(step.args(), "filename=subs.srt");
2387 }
2388
2389 #[test]
2390 fn builder_subtitles_srt_with_wrong_extension_should_return_invalid_config() {
2391 let result = FilterGraph::builder()
2392 .subtitles_srt("subtitles.vtt")
2393 .build();
2394 assert!(
2395 matches!(result, Err(FilterError::InvalidConfig { .. })),
2396 "expected InvalidConfig for wrong extension, got {result:?}"
2397 );
2398 if let Err(FilterError::InvalidConfig { reason }) = result {
2399 assert!(
2400 reason.contains("unsupported subtitle format"),
2401 "reason should mention unsupported format: {reason}"
2402 );
2403 }
2404 }
2405
2406 #[test]
2407 fn builder_subtitles_srt_with_no_extension_should_return_invalid_config() {
2408 let result = FilterGraph::builder()
2409 .subtitles_srt("subtitles_no_ext")
2410 .build();
2411 assert!(
2412 matches!(result, Err(FilterError::InvalidConfig { .. })),
2413 "expected InvalidConfig for missing extension, got {result:?}"
2414 );
2415 }
2416
2417 #[test]
2418 fn builder_subtitles_srt_with_nonexistent_file_should_return_invalid_config() {
2419 let result = FilterGraph::builder()
2420 .subtitles_srt("/nonexistent/path/subs_ab12cd.srt")
2421 .build();
2422 assert!(
2423 matches!(result, Err(FilterError::InvalidConfig { .. })),
2424 "expected InvalidConfig for nonexistent file, got {result:?}"
2425 );
2426 if let Err(FilterError::InvalidConfig { reason }) = result {
2427 assert!(
2428 reason.contains("subtitle file not found"),
2429 "reason should mention file not found: {reason}"
2430 );
2431 }
2432 }
2433
2434 #[test]
2435 fn filter_step_subtitles_ass_should_produce_correct_filter_name() {
2436 let step = FilterStep::SubtitlesAss {
2437 path: "subs.ass".to_owned(),
2438 };
2439 assert_eq!(step.filter_name(), "ass");
2440 }
2441
2442 #[test]
2443 fn filter_step_subtitles_ass_should_produce_correct_args() {
2444 let step = FilterStep::SubtitlesAss {
2445 path: "subs.ass".to_owned(),
2446 };
2447 assert_eq!(step.args(), "filename=subs.ass");
2448 }
2449
2450 #[test]
2451 fn filter_step_subtitles_ssa_should_produce_correct_filter_name() {
2452 let step = FilterStep::SubtitlesAss {
2453 path: "subs.ssa".to_owned(),
2454 };
2455 assert_eq!(step.filter_name(), "ass");
2456 }
2457
2458 #[test]
2459 fn builder_subtitles_ass_with_wrong_extension_should_return_invalid_config() {
2460 let result = FilterGraph::builder()
2461 .subtitles_ass("subtitles.srt")
2462 .build();
2463 assert!(
2464 matches!(result, Err(FilterError::InvalidConfig { .. })),
2465 "expected InvalidConfig for wrong extension, got {result:?}"
2466 );
2467 if let Err(FilterError::InvalidConfig { reason }) = result {
2468 assert!(
2469 reason.contains("unsupported subtitle format"),
2470 "reason should mention unsupported format: {reason}"
2471 );
2472 }
2473 }
2474
2475 #[test]
2476 fn builder_subtitles_ass_with_no_extension_should_return_invalid_config() {
2477 let result = FilterGraph::builder()
2478 .subtitles_ass("subtitles_no_ext")
2479 .build();
2480 assert!(
2481 matches!(result, Err(FilterError::InvalidConfig { .. })),
2482 "expected InvalidConfig for missing extension, got {result:?}"
2483 );
2484 }
2485
2486 #[test]
2487 fn builder_subtitles_ass_with_nonexistent_file_should_return_invalid_config() {
2488 let result = FilterGraph::builder()
2489 .subtitles_ass("/nonexistent/path/subs_ab12cd.ass")
2490 .build();
2491 assert!(
2492 matches!(result, Err(FilterError::InvalidConfig { .. })),
2493 "expected InvalidConfig for nonexistent .ass file, got {result:?}"
2494 );
2495 if let Err(FilterError::InvalidConfig { reason }) = result {
2496 assert!(
2497 reason.contains("subtitle file not found"),
2498 "reason should mention file not found: {reason}"
2499 );
2500 }
2501 }
2502
2503 #[test]
2504 fn builder_subtitles_ssa_with_nonexistent_file_should_return_invalid_config() {
2505 let result = FilterGraph::builder()
2506 .subtitles_ass("/nonexistent/path/subs_ab12cd.ssa")
2507 .build();
2508 assert!(
2509 matches!(result, Err(FilterError::InvalidConfig { .. })),
2510 "expected InvalidConfig for nonexistent .ssa file, got {result:?}"
2511 );
2512 }
2513
2514 #[test]
2515 fn filter_step_overlay_image_should_produce_correct_filter_name() {
2516 let step = FilterStep::OverlayImage {
2517 path: "logo.png".to_owned(),
2518 x: "10".to_owned(),
2519 y: "10".to_owned(),
2520 opacity: 1.0,
2521 };
2522 assert_eq!(step.filter_name(), "overlay");
2523 }
2524
2525 #[test]
2526 fn filter_step_overlay_image_should_produce_correct_args() {
2527 let step = FilterStep::OverlayImage {
2528 path: "logo.png".to_owned(),
2529 x: "W-w-10".to_owned(),
2530 y: "H-h-10".to_owned(),
2531 opacity: 0.7,
2532 };
2533 assert_eq!(step.args(), "W-w-10:H-h-10");
2534 }
2535
2536 #[test]
2537 fn builder_overlay_image_with_wrong_extension_should_return_invalid_config() {
2538 let result = FilterGraph::builder()
2539 .overlay_image("logo.jpg", "10", "10", 1.0)
2540 .build();
2541 assert!(
2542 matches!(result, Err(FilterError::InvalidConfig { .. })),
2543 "expected InvalidConfig for wrong extension, got {result:?}"
2544 );
2545 if let Err(FilterError::InvalidConfig { reason }) = result {
2546 assert!(
2547 reason.contains("unsupported image format"),
2548 "reason should mention unsupported format: {reason}"
2549 );
2550 }
2551 }
2552
2553 #[test]
2554 fn builder_overlay_image_with_no_extension_should_return_invalid_config() {
2555 let result = FilterGraph::builder()
2556 .overlay_image("logo_no_ext", "10", "10", 1.0)
2557 .build();
2558 assert!(
2559 matches!(result, Err(FilterError::InvalidConfig { .. })),
2560 "expected InvalidConfig for missing extension, got {result:?}"
2561 );
2562 }
2563
2564 #[test]
2565 fn builder_overlay_image_with_nonexistent_file_should_return_invalid_config() {
2566 let result = FilterGraph::builder()
2567 .overlay_image("/nonexistent/path/logo_ab12cd.png", "10", "10", 1.0)
2568 .build();
2569 assert!(
2570 matches!(result, Err(FilterError::InvalidConfig { .. })),
2571 "expected InvalidConfig for nonexistent file, got {result:?}"
2572 );
2573 if let Err(FilterError::InvalidConfig { reason }) = result {
2574 assert!(
2575 reason.contains("overlay image not found"),
2576 "reason should mention file not found: {reason}"
2577 );
2578 }
2579 }
2580
2581 #[test]
2582 fn builder_overlay_image_with_opacity_above_1_should_return_invalid_config() {
2583 let result = FilterGraph::builder()
2584 .overlay_image("/nonexistent/logo.png", "10", "10", 1.1)
2585 .build();
2586 assert!(
2587 matches!(result, Err(FilterError::InvalidConfig { .. })),
2588 "expected InvalidConfig for opacity > 1.0, got {result:?}"
2589 );
2590 if let Err(FilterError::InvalidConfig { reason }) = result {
2591 assert!(
2592 reason.contains("opacity"),
2593 "reason should mention opacity: {reason}"
2594 );
2595 }
2596 }
2597
2598 #[test]
2599 fn builder_overlay_image_with_negative_opacity_should_return_invalid_config() {
2600 let result = FilterGraph::builder()
2601 .overlay_image("/nonexistent/logo.png", "10", "10", -0.1)
2602 .build();
2603 assert!(
2604 matches!(result, Err(FilterError::InvalidConfig { .. })),
2605 "expected InvalidConfig for opacity < 0.0, got {result:?}"
2606 );
2607 }
2608
2609 #[test]
2610 fn filter_step_ticker_should_produce_correct_filter_name() {
2611 let step = FilterStep::Ticker {
2612 text: "Breaking news".to_owned(),
2613 y: "h-50".to_owned(),
2614 speed_px_per_sec: 100.0,
2615 font_size: 24,
2616 font_color: "white".to_owned(),
2617 };
2618 assert_eq!(step.filter_name(), "drawtext");
2619 }
2620
2621 #[test]
2622 fn filter_step_ticker_should_produce_correct_args() {
2623 let step = FilterStep::Ticker {
2624 text: "Breaking news".to_owned(),
2625 y: "h-50".to_owned(),
2626 speed_px_per_sec: 100.0,
2627 font_size: 24,
2628 font_color: "white".to_owned(),
2629 };
2630 let args = step.args();
2631 assert!(
2632 args.contains("text='Breaking news'"),
2633 "args should contain escaped text: {args}"
2634 );
2635 assert!(
2636 args.contains("x=w-t*100"),
2637 "args should contain scrolling x expression: {args}"
2638 );
2639 assert!(args.contains("y=h-50"), "args should contain y: {args}");
2640 assert!(
2641 args.contains("fontsize=24"),
2642 "args should contain fontsize: {args}"
2643 );
2644 assert!(
2645 args.contains("fontcolor=white"),
2646 "args should contain fontcolor: {args}"
2647 );
2648 }
2649
2650 #[test]
2651 fn filter_step_ticker_should_escape_special_characters_in_text() {
2652 let step = FilterStep::Ticker {
2653 text: "colon:backslash\\apostrophe'".to_owned(),
2654 y: "10".to_owned(),
2655 speed_px_per_sec: 50.0,
2656 font_size: 20,
2657 font_color: "red".to_owned(),
2658 };
2659 let args = step.args();
2660 assert!(
2661 args.contains("\\:"),
2662 "colon should be escaped in args: {args}"
2663 );
2664 assert!(
2665 args.contains("\\'"),
2666 "apostrophe should be escaped in args: {args}"
2667 );
2668 assert!(
2669 args.contains("\\\\"),
2670 "backslash should be escaped in args: {args}"
2671 );
2672 }
2673
2674 #[test]
2675 fn builder_ticker_with_empty_text_should_return_invalid_config() {
2676 let result = FilterGraph::builder()
2677 .ticker("", "h-50", 100.0, 24, "white")
2678 .build();
2679 assert!(
2680 matches!(result, Err(FilterError::InvalidConfig { .. })),
2681 "expected InvalidConfig for empty text, got {result:?}"
2682 );
2683 if let Err(FilterError::InvalidConfig { reason }) = result {
2684 assert!(
2685 reason.contains("ticker text must not be empty"),
2686 "reason should mention empty text: {reason}"
2687 );
2688 }
2689 }
2690
2691 #[test]
2692 fn builder_ticker_with_zero_speed_should_return_invalid_config() {
2693 let result = FilterGraph::builder()
2694 .ticker("Breaking news", "h-50", 0.0, 24, "white")
2695 .build();
2696 assert!(
2697 matches!(result, Err(FilterError::InvalidConfig { .. })),
2698 "expected InvalidConfig for speed = 0.0, got {result:?}"
2699 );
2700 if let Err(FilterError::InvalidConfig { reason }) = result {
2701 assert!(
2702 reason.contains("speed_px_per_sec"),
2703 "reason should mention speed_px_per_sec: {reason}"
2704 );
2705 }
2706 }
2707
2708 #[test]
2709 fn builder_ticker_with_negative_speed_should_return_invalid_config() {
2710 let result = FilterGraph::builder()
2711 .ticker("Breaking news", "h-50", -50.0, 24, "white")
2712 .build();
2713 assert!(
2714 matches!(result, Err(FilterError::InvalidConfig { .. })),
2715 "expected InvalidConfig for negative speed, got {result:?}"
2716 );
2717 }
2718
2719 #[test]
2720 fn filter_step_speed_should_produce_correct_filter_name() {
2721 let step = FilterStep::Speed { factor: 2.0 };
2722 assert_eq!(step.filter_name(), "setpts");
2723 }
2724
2725 #[test]
2726 fn filter_step_speed_should_produce_correct_args_for_double_speed() {
2727 let step = FilterStep::Speed { factor: 2.0 };
2728 assert_eq!(step.args(), "PTS/2");
2729 }
2730
2731 #[test]
2732 fn filter_step_speed_should_produce_correct_args_for_half_speed() {
2733 let step = FilterStep::Speed { factor: 0.5 };
2734 assert_eq!(step.args(), "PTS/0.5");
2735 }
2736
2737 #[test]
2738 fn builder_speed_with_factor_below_minimum_should_return_invalid_config() {
2739 let result = FilterGraph::builder().speed(0.09).build();
2740 assert!(
2741 matches!(result, Err(FilterError::InvalidConfig { .. })),
2742 "expected InvalidConfig for factor below 0.1, got {result:?}"
2743 );
2744 if let Err(FilterError::InvalidConfig { reason }) = result {
2745 assert!(
2746 reason.contains("speed factor"),
2747 "reason should mention speed factor: {reason}"
2748 );
2749 }
2750 }
2751
2752 #[test]
2753 fn builder_speed_with_factor_above_maximum_should_return_invalid_config() {
2754 let result = FilterGraph::builder().speed(100.1).build();
2755 assert!(
2756 matches!(result, Err(FilterError::InvalidConfig { .. })),
2757 "expected InvalidConfig for factor above 100.0, got {result:?}"
2758 );
2759 }
2760
2761 #[test]
2762 fn builder_speed_with_zero_factor_should_return_invalid_config() {
2763 let result = FilterGraph::builder().speed(0.0).build();
2764 assert!(
2765 matches!(result, Err(FilterError::InvalidConfig { .. })),
2766 "expected InvalidConfig for factor 0.0, got {result:?}"
2767 );
2768 }
2769
2770 #[test]
2771 fn builder_speed_at_boundary_values_should_succeed() {
2772 let low = FilterGraph::builder().speed(0.1).build();
2773 assert!(low.is_ok(), "speed(0.1) should succeed, got {low:?}");
2774 let high = FilterGraph::builder().speed(100.0).build();
2775 assert!(high.is_ok(), "speed(100.0) should succeed, got {high:?}");
2776 }
2777
2778 #[test]
2779 fn filter_step_reverse_should_produce_correct_filter_name_and_empty_args() {
2780 let step = FilterStep::Reverse;
2781 assert_eq!(step.filter_name(), "reverse");
2782 assert_eq!(step.args(), "");
2783 }
2784
2785 #[test]
2786 fn builder_reverse_should_succeed() {
2787 let result = FilterGraph::builder().reverse().build();
2788 assert!(
2789 result.is_ok(),
2790 "reverse must build successfully, got {result:?}"
2791 );
2792 }
2793
2794 #[test]
2795 fn filter_step_freeze_frame_should_produce_correct_filter_name() {
2796 let step = FilterStep::FreezeFrame {
2797 pts: 2.0,
2798 duration: 3.0,
2799 };
2800 assert_eq!(step.filter_name(), "loop");
2801 }
2802
2803 #[test]
2804 fn filter_step_freeze_frame_should_produce_correct_args() {
2805 let step = FilterStep::FreezeFrame {
2806 pts: 2.0,
2807 duration: 3.0,
2808 };
2809 assert_eq!(step.args(), "loop=75:size=1:start=50");
2811 }
2812
2813 #[test]
2814 fn filter_step_freeze_frame_at_zero_pts_should_produce_start_zero() {
2815 let step = FilterStep::FreezeFrame {
2816 pts: 0.0,
2817 duration: 1.0,
2818 };
2819 assert_eq!(step.args(), "loop=25:size=1:start=0");
2820 }
2821
2822 #[test]
2823 fn builder_freeze_frame_with_valid_params_should_succeed() {
2824 let result = FilterGraph::builder().freeze_frame(2.0, 3.0).build();
2825 assert!(
2826 result.is_ok(),
2827 "freeze_frame(2.0, 3.0) must build successfully, got {result:?}"
2828 );
2829 }
2830
2831 #[test]
2832 fn builder_freeze_frame_with_negative_pts_should_return_invalid_config() {
2833 let result = FilterGraph::builder().freeze_frame(-1.0, 3.0).build();
2834 assert!(
2835 matches!(result, Err(FilterError::InvalidConfig { .. })),
2836 "expected InvalidConfig for negative pts, got {result:?}"
2837 );
2838 }
2839
2840 #[test]
2841 fn builder_freeze_frame_with_zero_duration_should_return_invalid_config() {
2842 let result = FilterGraph::builder().freeze_frame(2.0, 0.0).build();
2843 assert!(
2844 matches!(result, Err(FilterError::InvalidConfig { .. })),
2845 "expected InvalidConfig for zero duration, got {result:?}"
2846 );
2847 }
2848
2849 #[test]
2850 fn builder_freeze_frame_with_negative_duration_should_return_invalid_config() {
2851 let result = FilterGraph::builder().freeze_frame(2.0, -1.0).build();
2852 assert!(
2853 matches!(result, Err(FilterError::InvalidConfig { .. })),
2854 "expected InvalidConfig for negative duration, got {result:?}"
2855 );
2856 }
2857
2858 #[test]
2859 fn filter_step_concat_video_should_have_correct_filter_name() {
2860 let step = FilterStep::ConcatVideo { n: 2 };
2861 assert_eq!(step.filter_name(), "concat");
2862 }
2863
2864 #[test]
2865 fn filter_step_concat_video_should_produce_correct_args_for_n2() {
2866 let step = FilterStep::ConcatVideo { n: 2 };
2867 assert_eq!(step.args(), "n=2:v=1:a=0");
2868 }
2869
2870 #[test]
2871 fn filter_step_concat_video_should_produce_correct_args_for_n3() {
2872 let step = FilterStep::ConcatVideo { n: 3 };
2873 assert_eq!(step.args(), "n=3:v=1:a=0");
2874 }
2875
2876 #[test]
2877 fn builder_concat_video_valid_should_build_successfully() {
2878 let result = FilterGraph::builder().concat_video(2).build();
2879 assert!(
2880 result.is_ok(),
2881 "concat_video(2) must build successfully, got {result:?}"
2882 );
2883 }
2884
2885 #[test]
2886 fn builder_concat_video_with_n1_should_return_invalid_config() {
2887 let result = FilterGraph::builder().concat_video(1).build();
2888 assert!(
2889 matches!(result, Err(FilterError::InvalidConfig { .. })),
2890 "expected InvalidConfig for n=1, got {result:?}"
2891 );
2892 }
2893
2894 #[test]
2895 fn builder_concat_video_with_n0_should_return_invalid_config() {
2896 let result = FilterGraph::builder().concat_video(0).build();
2897 assert!(
2898 matches!(result, Err(FilterError::InvalidConfig { .. })),
2899 "expected InvalidConfig for n=0, got {result:?}"
2900 );
2901 }
2902
2903 #[test]
2904 fn filter_step_join_with_dissolve_should_have_correct_filter_name() {
2905 let step = FilterStep::JoinWithDissolve {
2906 clip_a_end: 4.0,
2907 clip_b_start: 1.0,
2908 dissolve_dur: 1.0,
2909 };
2910 assert_eq!(step.filter_name(), "xfade");
2911 }
2912
2913 #[test]
2914 fn filter_step_join_with_dissolve_should_produce_correct_args() {
2915 let step = FilterStep::JoinWithDissolve {
2916 clip_a_end: 4.0,
2917 clip_b_start: 1.0,
2918 dissolve_dur: 1.0,
2919 };
2920 assert_eq!(
2921 step.args(),
2922 "transition=dissolve:duration=1:offset=4",
2923 "args must match xfade format for join_with_dissolve"
2924 );
2925 }
2926
2927 #[test]
2928 fn builder_join_with_dissolve_valid_should_build_successfully() {
2929 let result = FilterGraph::builder()
2930 .join_with_dissolve(4.0, 1.0, 1.0)
2931 .build();
2932 assert!(
2933 result.is_ok(),
2934 "join_with_dissolve(4.0, 1.0, 1.0) must build successfully, got {result:?}"
2935 );
2936 }
2937
2938 #[test]
2939 fn builder_join_with_dissolve_with_zero_dissolve_dur_should_return_invalid_config() {
2940 let result = FilterGraph::builder()
2941 .join_with_dissolve(4.0, 1.0, 0.0)
2942 .build();
2943 assert!(
2944 matches!(result, Err(FilterError::InvalidConfig { .. })),
2945 "expected InvalidConfig for dissolve_dur=0.0, got {result:?}"
2946 );
2947 }
2948
2949 #[test]
2950 fn builder_join_with_dissolve_with_negative_dissolve_dur_should_return_invalid_config() {
2951 let result = FilterGraph::builder()
2952 .join_with_dissolve(4.0, 1.0, -1.0)
2953 .build();
2954 assert!(
2955 matches!(result, Err(FilterError::InvalidConfig { .. })),
2956 "expected InvalidConfig for dissolve_dur=-1.0, got {result:?}"
2957 );
2958 }
2959
2960 #[test]
2961 fn builder_join_with_dissolve_with_zero_clip_a_end_should_return_invalid_config() {
2962 let result = FilterGraph::builder()
2963 .join_with_dissolve(0.0, 1.0, 1.0)
2964 .build();
2965 assert!(
2966 matches!(result, Err(FilterError::InvalidConfig { .. })),
2967 "expected InvalidConfig for clip_a_end=0.0, got {result:?}"
2968 );
2969 }
2970}