//! CLI and profile-file parsing, including the invalid cases a launcher could //! hand us at 09:55 on a Sunday. use std::path::PathBuf; use video_output::cli::{ parse_args, parse_args_with_loader, parse_fps, parse_size, settings_from_profile, Command, ParseError, Settings, Source, }; use video_output::display::DisplaySelector; use video_output::log::Level; fn args(v: &[&str]) -> Vec { v.iter().map(|s| s.to_string()).collect() } fn run_settings(v: &[&str]) -> Settings { match parse_args(&args(v)) { Ok(Command::Run(s)) => *s, other => panic!("expected Run, got {other:?}"), } } /// A loader that serves one in-memory profile, so tests never touch /etc. fn loader(name: &'static str, body: &'static str) -> impl Fn(&str) -> Result<(PathBuf, String), ParseError> { move |asked: &str| { if asked == name { Ok((PathBuf::from(format!("/test/{asked}.toml")), body.to_string())) } else { Err(ParseError(format!("no such profile {asked:?}"))) } } } // --- subcommands ------------------------------------------------------------ #[test] fn bare_subcommands_are_recognised() { assert_eq!(parse_args(&args(&["displays"])).unwrap(), Command::ListDisplays); assert_eq!(parse_args(&args(&["devices"])).unwrap(), Command::ListDevices); assert_eq!(parse_args(&args(&["--help"])).unwrap(), Command::Help); assert_eq!(parse_args(&args(&["-h"])).unwrap(), Command::Help); assert_eq!(parse_args(&args(&["--version"])).unwrap(), Command::Version); assert_eq!( parse_args(&args(&["ndi-sources"])).unwrap(), Command::ListNdiSources { timeout_secs: 5, extra_ips: Vec::new() } ); assert_eq!( parse_args(&args(&["ndi-sources", "--timeout", "20"])).unwrap(), Command::ListNdiSources { timeout_secs: 20, extra_ips: Vec::new() } ); } #[test] fn ndi_sources_accepts_repeated_unicast_hints() { assert_eq!( parse_args(&args(&[ "ndi-sources", "--extra-ip", "10.0.0.5", "--timeout", "8", "--extra-ip", "ndi.example.test", ])) .unwrap(), Command::ListNdiSources { timeout_secs: 8, extra_ips: vec!["10.0.0.5".into(), "ndi.example.test".into()], } ); assert_eq!( parse_args(&args(&["ndi-sources", "--timeout", "300"])).unwrap(), Command::ListNdiSources { timeout_secs: 300, extra_ips: Vec::new() } ); } #[test] fn ndi_sources_rejects_bad_options_and_values() { assert!(parse_args(&args(&["ndi-sources", "--timeout"])).is_err()); assert!(parse_args(&args(&["ndi-sources", "--timeout", "soon"])).is_err()); assert!(parse_args(&args(&["ndi-sources", "--timeout", "0"])).is_err()); assert!(parse_args(&args(&["ndi-sources", "--timeout", "301"])).is_err()); assert!(parse_args(&args(&["ndi-sources", "--timeout", "18446744073709551615"])).is_err()); assert!(parse_args(&args(&["ndi-sources", "--extra-ip"])).is_err()); assert!(parse_args(&args(&["ndi-sources", "--source", "anything"])).is_err()); } #[test] fn no_arguments_is_an_error_with_usage() { let e = parse_args(&[]).unwrap_err(); assert!(e.0.contains("USAGE"), "should print usage, got: {}", e.0); } // --- camera ----------------------------------------------------------------- #[test] fn camera_defaults_match_the_atem() { let s = run_settings(&["camera"]); match s.source { Source::Camera { device, width, height, fps_num, fps_den } => { assert_eq!(device, "/dev/video0"); assert_eq!((width, height), (1920, 1080)); assert_eq!((fps_num, fps_den), (30000, 1001), "29.97 must stay exact"); } other => panic!("expected Camera, got {other:?}"), } assert_eq!(s.display, DisplaySelector::Primary); assert!(s.vsync); assert!(s.fullscreen); } #[test] fn camera_accepts_the_documented_invocation() { let s = run_settings(&["camera", "--device", "/dev/video2", "--display", "HDMI-A-2"]); match s.source { Source::Camera { device, .. } => assert_eq!(device, "/dev/video2"), other => panic!("expected Camera, got {other:?}"), } assert_eq!(s.display, DisplaySelector::Name("HDMI-A-2".into())); } #[test] fn camera_options_override_defaults() { let s = run_settings(&[ "camera", "--size", "1280x720", "--fps", "60", "--no-vsync", "--windowed", "--log", "debug", "--stats", "0", "--display", "1", ]); match s.source { Source::Camera { width, height, fps_num, fps_den, .. } => { assert_eq!((width, height), (1280, 720)); assert_eq!((fps_num, fps_den), (60, 1)); } other => panic!("expected Camera, got {other:?}"), } assert!(!s.vsync); assert!(!s.fullscreen); assert_eq!(s.log_level, Some(Level::Debug)); assert_eq!(s.stats_interval, 0); assert_eq!(s.display, DisplaySelector::Index(1)); } // --- ndi -------------------------------------------------------------------- #[test] fn ndi_defaults_to_the_first_source_found() { let s = run_settings(&["ndi"]); match s.source { Source::Ndi { source, extra_ips } => { assert_eq!(source, None); assert!(extra_ips.is_empty()); } other => panic!("expected Ndi, got {other:?}"), } } #[test] fn ndi_accepts_a_source_name_and_repeated_ip_hints() { let s = run_settings(&[ "ndi", "--source", "ProPresenter Lower Thirds", "--extra-ip", "10.0.0.5", "--extra-ip", "10.0.0.6", "--display", "HDMI-A-1", ]); match s.source { Source::Ndi { source, extra_ips } => { assert_eq!(source.as_deref(), Some("ProPresenter Lower Thirds")); assert_eq!(extra_ips, vec!["10.0.0.5", "10.0.0.6"]); } other => panic!("expected Ndi, got {other:?}"), } assert_eq!(s.display, DisplaySelector::Name("HDMI-A-1".into())); } // --- rejected input --------------------------------------------------------- #[test] fn options_are_rejected_for_the_wrong_mode() { assert!(parse_args(&args(&["camera", "--source", "x"])).is_err()); assert!(parse_args(&args(&["camera", "--extra-ip", "1.2.3.4"])).is_err()); assert!(parse_args(&args(&["ndi", "--device", "/dev/video0"])).is_err()); assert!(parse_args(&args(&["ndi", "--size", "1920x1080"])).is_err()); assert!(parse_args(&args(&["ndi", "--fps", "30"])).is_err()); } #[test] fn unknown_options_and_missing_values_are_rejected() { assert!(parse_args(&args(&["camera", "--nonsense"])).is_err()); assert!(parse_args(&args(&["camera", "--device"])).is_err()); assert!(parse_args(&args(&["camera", "--display"])).is_err()); assert!(parse_args(&args(&["--display", "HDMI-1"])).is_err(), "options need a command first"); } #[test] fn malformed_values_are_rejected() { assert!(parse_args(&args(&["camera", "--size", "1920"])).is_err()); assert!(parse_args(&args(&["camera", "--size", "0x1080"])).is_err()); assert!(parse_args(&args(&["camera", "--size", "axb"])).is_err()); assert!(parse_args(&args(&["camera", "--fps", "0"])).is_err()); assert!(parse_args(&args(&["camera", "--fps", "-5"])).is_err()); assert!(parse_args(&args(&["camera", "--fps", "abc"])).is_err()); assert!(parse_args(&args(&["camera", "--log", "louder"])).is_err()); assert!(parse_args(&args(&["camera", "--stats", "soon"])).is_err()); assert!(parse_args(&args(&["camera", "--device", ""])).is_err()); } #[test] fn an_unknown_profile_name_lists_where_it_looked() { let e = parse_args(&args(&["definitely-not-a-profile"])).unwrap_err(); assert!(e.0.contains("/etc/video-output/definitely-not-a-profile.toml"), "got: {}", e.0); } #[test] fn profile_names_cannot_escape_the_search_path() { assert!(parse_args(&args(&["../../etc/passwd"])).is_err()); assert!(parse_args(&args(&["/etc/shadow"])).is_err()); } // --- size and fps parsing --------------------------------------------------- #[test] fn size_parsing_accepts_the_forms_people_type() { assert_eq!(parse_size("1920x1080").unwrap(), (1920, 1080)); assert_eq!(parse_size("1920X1080").unwrap(), (1920, 1080)); assert_eq!(parse_size(" 1280 x 720 ").unwrap(), (1280, 720)); } /// Broadcast rates must stay exact rationals. Sending 2997/100 to V4L2 makes a /// UVC device round to 30 and free-run against the source. #[test] fn fps_parsing_snaps_decimals_to_exact_broadcast_rationals() { assert_eq!(parse_fps("29.97").unwrap(), (30000, 1001)); assert_eq!(parse_fps("59.94").unwrap(), (60000, 1001)); assert_eq!(parse_fps("23.976").unwrap(), (24000, 1001)); assert_eq!(parse_fps("30000/1001").unwrap(), (30000, 1001)); assert_eq!(parse_fps("30").unwrap(), (30, 1)); assert_eq!(parse_fps("25").unwrap(), (25, 1)); } #[test] fn fps_parsing_rejects_nonsense() { assert!(parse_fps("0").is_err()); assert!(parse_fps("0/30").is_err()); assert!(parse_fps("30/0").is_err()); assert!(parse_fps("-1").is_err()); assert!(parse_fps("inf").is_err()); assert!(parse_fps("").is_err()); } // --- profiles --------------------------------------------------------------- #[test] fn a_camera_profile_produces_the_same_settings_as_flags() { let s = settings_from_profile( r#" # ATEM multiview feed mode = "camera" device = "/dev/video0" display = "HXA BMD HDMI" size = "1920x1080" fps = "29.97" "#, ) .unwrap(); match s.source { Source::Camera { device, width, height, fps_num, fps_den } => { assert_eq!(device, "/dev/video0"); assert_eq!((width, height), (1920, 1080)); assert_eq!((fps_num, fps_den), (30000, 1001)); } other => panic!("expected Camera, got {other:?}"), } assert_eq!(s.display, DisplaySelector::Name("HXA BMD HDMI".into())); } #[test] fn an_ndi_profile_accumulates_repeated_ip_hints() { let s = settings_from_profile( r#" mode = "ndi" source = "ProPresenter Lower Thirds" display = "HDMI-A-1" extra-ip = "10.0.0.5" extra-ip = "10.0.0.6" vsync = false stats = 30 "#, ) .unwrap(); match s.source { Source::Ndi { source, extra_ips } => { assert_eq!(source.as_deref(), Some("ProPresenter Lower Thirds")); assert_eq!(extra_ips, vec!["10.0.0.5", "10.0.0.6"]); } other => panic!("expected Ndi, got {other:?}"), } assert!(!s.vsync); assert_eq!(s.stats_interval, 30); } #[test] fn profile_keys_accept_underscores_and_leading_dashes() { let s = settings_from_profile("mode = \"camera\"\n--log_level = \"warn\"\n").unwrap(); assert_eq!(s.log_level, Some(Level::Warn)); } #[test] fn comments_and_blank_lines_are_ignored_but_hashes_in_values_survive() { let s = settings_from_profile( "\n# leading comment\nmode = \"ndi\" # trailing comment\n\nsource = \"Rig #2 Lower Thirds\"\n", ) .unwrap(); match s.source { Source::Ndi { source, .. } => assert_eq!(source.as_deref(), Some("Rig #2 Lower Thirds")), other => panic!("expected Ndi, got {other:?}"), } } /// A typo must fail at startup rather than silently running the wrong config. #[test] fn unknown_profile_keys_are_rejected() { let e = settings_from_profile("mode = \"camera\"\ndisplya = \"HDMI-1\"\n").unwrap_err(); assert!(e.0.contains("displya"), "got: {}", e.0); } #[test] fn invalid_profiles_are_rejected() { assert!(settings_from_profile("device = \"/dev/video0\"").is_err(), "missing mode"); assert!(settings_from_profile("mode = \"telepathy\"").is_err(), "unknown mode"); assert!(settings_from_profile("mode = \"camera\"\nthis line has no equals\n").is_err()); assert!(settings_from_profile("mode = \"camera\"\n[section]\nkey = 1\n").is_err(), "no tables"); assert!(settings_from_profile("mode = \"camera\"\ndevice = \"a\"\ndevice = \"b\"\n").is_err(), "duplicate key"); assert!(settings_from_profile("mode = \"camera\"\nvsync = \"maybe\"\n").is_err()); assert!(settings_from_profile("mode = \"camera\"\nfps = \"quick\"\n").is_err()); assert!(settings_from_profile("mode = \"ndi\"\nsize = \"1920x1080\"\n").is_err(), "size is camera-only"); assert!(settings_from_profile("mode = \"camera\"\nextra-ip = \"1.2.3.4\"\n").is_err(), "extra-ip is ndi-only"); } #[test] fn booleans_accept_the_usual_spellings() { for (text, want) in [("true", true), ("yes", true), ("on", true), ("1", true), ("false", false), ("no", false), ("off", false), ("0", false)] { let s = settings_from_profile(&format!("mode = \"camera\"\nvsync = {text}\n")).unwrap(); assert_eq!(s.vsync, want, "vsync = {text}"); } } // --- profile + flag interaction --------------------------------------------- #[test] fn a_profile_name_resolves_through_the_loader() { let load = loader("multiview", "mode = \"camera\"\ndevice = \"/dev/video0\"\ndisplay = \"BMD\"\n"); match parse_args_with_loader(&args(&["multiview"]), &load).unwrap() { Command::Run(s) => { assert_eq!(s.display, DisplaySelector::Name("BMD".into())); match &s.source { Source::Camera { device, .. } => assert_eq!(device, "/dev/video0"), other => panic!("expected Camera, got {other:?}"), } } other => panic!("expected Run, got {other:?}"), } } /// Flags after a profile name win, so an operator can override one setting /// without editing the file. #[test] fn flags_override_profile_values() { let load = loader("multiview", "mode = \"camera\"\ndevice = \"/dev/video0\"\ndisplay = \"BMD\"\n"); match parse_args_with_loader(&args(&["multiview", "--display", "Sceptre", "--no-vsync"]), &load).unwrap() { Command::Run(s) => { assert_eq!(s.display, DisplaySelector::Name("Sceptre".into())); assert!(!s.vsync); } other => panic!("expected Run, got {other:?}"), } } #[test] fn a_profile_error_names_the_file() { let load = loader("broken", "mode = \"camera\"\nbogus = 1\n"); let e = parse_args_with_loader(&args(&["broken"]), &load).unwrap_err(); assert!(e.0.contains("/test/broken.toml"), "got: {}", e.0); assert!(e.0.contains("bogus"), "got: {}", e.0); }