Files
2026-08-29 17:11:55 -05:00

381 lines
14 KiB
Rust

//! 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<String> {
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);
}