use std::collections::HashMap;
use super::*;
fn ladder() -> HlsLadder {
HlsLadder::new("input.mp4", "out")
.rendition(1280, 720, "2800k")
.rendition(640, 360, "800k")
}
#[test]
fn split_desc_single_rendition() {
let renditions = vec![Rendition::new(640, 360, "800k")];
assert_eq!(
build_split_desc(&renditions),
"[0:v]split=1[s0];[s0]scale=640:360,setsar=1,format=yuv420p[v0]"
);
}
#[test]
fn split_desc_three_renditions() {
let renditions = vec![
Rendition::new(1920, 1080, "5000k"),
Rendition::new(1280, 720, "2800k"),
Rendition::new(640, 360, "800k"),
];
assert_eq!(
build_split_desc(&renditions),
"[0:v]split=3[s0][s1][s2];\
[s0]scale=1920:1080,setsar=1,format=yuv420p[v0];\
[s1]scale=1280:720,setsar=1,format=yuv420p[v1];\
[s2]scale=640:360,setsar=1,format=yuv420p[v2]"
);
}
#[test]
fn split_desc_every_branch_has_yuv420p() {
let renditions = vec![
Rendition::new(1280, 720, "2800k"),
Rendition::new(640, 360, "800k"),
];
let desc = build_split_desc(&renditions);
assert_eq!(desc.matches("format=yuv420p").count(), 2);
}
#[test]
fn gop_exact_integer_rates() {
assert_eq!(compute_gop_frames(30, 1, 6_000_000), 180);
assert_eq!(compute_gop_frames(25, 1, 6_000_000), 150);
}
#[test]
fn gop_ntsc_rational_not_broken_by_round() {
assert_eq!(compute_gop_frames(30000, 1001, 6_000_000), 180);
assert_eq!(compute_gop_frames(24000, 1001, 1_000_000), 24);
}
#[test]
fn gop_uses_ceil_not_round() {
assert_eq!(compute_gop_frames(7, 3, 1_000_000), 3);
}
#[test]
fn gop_degenerate_inputs_clamp_to_one() {
assert_eq!(compute_gop_frames(0, 1, 6_000_000), 1);
assert_eq!(compute_gop_frames(30, 0, 6_000_000), 1);
assert_eq!(compute_gop_frames(30, 1, 0), 1);
}
#[test]
fn parse_bitrate_suffixes() {
assert_eq!(parse_bitrate_bps("800000").unwrap(), 800_000);
assert_eq!(parse_bitrate_bps("800k").unwrap(), 800_000);
assert_eq!(parse_bitrate_bps("800K").unwrap(), 800_000);
assert_eq!(parse_bitrate_bps("5M").unwrap(), 5_000_000);
assert_eq!(parse_bitrate_bps("1.5M").unwrap(), 1_500_000);
}
#[test]
fn parse_bitrate_rejects_garbage() {
assert!(parse_bitrate_bps("").is_err());
assert!(parse_bitrate_bps("abc").is_err());
assert!(parse_bitrate_bps("0").is_err());
assert!(parse_bitrate_bps("-5M").is_err());
}
#[test]
fn bandwidth_folds_audio_and_overhead() {
assert_eq!(compute_bandwidth(5_000_000, Some(128_000)), 5_640_800);
assert_eq!(compute_bandwidth(5_000_000, None), 5_500_000);
}
#[test]
fn master_variants_carry_resolution_and_relative_uri() {
let variants = ladder().build_master_variants(true).unwrap();
assert_eq!(variants.len(), 2);
let v720 = &variants[0];
assert_eq!((v720.width, v720.height), (1280, 720));
assert_eq!(v720.uri, "720p/index.m3u8");
assert_eq!(v720.bandwidth, 3_220_800);
assert!(v720.codecs.is_none());
}
#[test]
fn master_variants_omit_audio_when_absent() {
let variants = ladder().build_master_variants(false).unwrap();
assert_eq!(variants[1].bandwidth, 880_000);
}
#[test]
fn master_variants_custom_name_becomes_uri() {
let variants = HlsLadder::new("input.mp4", "out")
.rendition_named(Rendition::new(640, 360, "800k").with_name("low"))
.build_master_variants(false)
.unwrap();
assert_eq!(variants[0].uri, "low/index.m3u8");
}
#[test]
fn validate_accepts_default_ladder() {
assert!(ladder().validate().is_ok());
}
#[test]
fn validate_rejects_empty_ladder() {
let l = HlsLadder::new("input.mp4", "out");
assert!(matches!(l.validate(), Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn validate_rejects_odd_dimensions() {
let l = HlsLadder::new("input.mp4", "out").rendition(641, 360, "800k");
assert!(l.validate().is_err());
}
#[test]
fn validate_rejects_non_multiple_segment_gop() {
let l = ladder().segment_duration(6.0).gop_seconds(4.0);
assert!(l.validate().is_err());
assert!(ladder()
.segment_duration(6.0)
.gop_seconds(2.0)
.validate()
.is_ok());
}
#[test]
fn validate_rejects_bad_names() {
for bad in ["../escape", "a/b", "..", "/abs"] {
let l = HlsLadder::new("input.mp4", "out")
.rendition_named(Rendition::new(640, 360, "800k").with_name(bad));
assert!(l.validate().is_err(), "name '{bad}' should be rejected");
}
}
#[test]
fn validate_rejects_duplicate_names() {
let l = HlsLadder::new("input.mp4", "out")
.rendition(640, 360, "800k")
.rendition(480, 360, "600k");
assert!(l.validate().is_err());
}
#[test]
fn validate_rejects_nonpositive_fps_override() {
assert!(ladder().fps(0, 1).validate().is_err());
assert!(ladder().fps(30, 0).validate().is_err());
assert!(ladder().fps(30, 1).validate().is_ok());
}
#[test]
fn resolve_fps_uses_override() {
assert_eq!(
ladder().fps(30000, 1001).resolve_fps().unwrap(),
(30000, 1001)
);
}
#[test]
fn compute_bandwidth_saturates_on_overflow() {
assert_eq!(compute_bandwidth(u64::MAX, Some(128_000)), u64::MAX);
}
#[test]
fn path_segment_rejects_traversal_and_control_chars() {
assert!(validate_path_segment("../../etc", "x").is_err());
assert!(validate_path_segment("a\nBANDWIDTH=9", "x").is_err());
assert!(validate_path_segment("a/b", "x").is_err());
assert!(validate_path_segment("#low", "x").is_err()); assert!(validate_path_segment("master.m3u8", "x").is_ok());
assert!(validate_path_segment("720p", "x").is_ok());
}
#[test]
fn validate_rejects_codecs_injection_and_master_collision() {
assert!(ladder().codecs("avc1.640028\"\n#EXT").validate().is_err());
assert!(ladder().master("720p").validate().is_err());
assert!(ladder().validate().is_ok());
}
fn opts(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn segment_type_defaults_to_mpegts() {
assert_eq!(HlsSegmentType::default(), HlsSegmentType::MpegTs);
assert_eq!(ladder().segment_type, HlsSegmentType::MpegTs);
}
#[test]
fn mpegts_rendition_output_is_frozen() {
let l = ladder();
let output = l.rendition_output(0, &l.renditions[0], "180", "6").unwrap();
assert_eq!(output.url.as_deref(), Some("out/720p/index.m3u8"));
assert_eq!(output.format.as_deref(), Some("hls"));
assert_eq!(output.video_codec.as_deref(), Some("libx264"));
assert_eq!(output.audio_codec.as_deref(), Some("aac"));
assert_eq!(output.pix_fmt.as_deref(), Some("yuv420p"));
let maps: Vec<(&str, bool)> = output
.stream_map_specs
.iter()
.map(|spec| (spec.linklabel.as_str(), spec.copy))
.collect();
assert_eq!(maps, vec![("[v0]", false), ("0:a:0?", false)]);
assert_eq!(
output.format_opts,
Some(opts(&[
("hls_time", "6"),
("hls_playlist_type", "vod"),
("hls_segment_filename", "out/720p/seg_%05d.ts"),
]))
);
assert_eq!(
output.video_codec_opts,
Some(opts(&[
("b", "2800k"),
("g", "180"),
("keyint_min", "180"),
("sc_threshold", "0"),
("maxrate", "2800k"),
("bufsize", "5600000"),
("x264-params", "scenecut=0:open-gop=0"),
]))
);
assert_eq!(output.audio_codec_opts, Some(opts(&[("b", "128k")])));
}
#[test]
fn fmp4_rendition_output_wires_fmp4_options() {
let l = ladder().segment_type(HlsSegmentType::Fmp4);
let output = l.rendition_output(0, &l.renditions[0], "180", "6").unwrap();
assert_eq!(
output.format_opts,
Some(opts(&[
("hls_time", "6"),
("hls_playlist_type", "vod"),
("hls_segment_filename", "out/720p/seg_%05d.m4s"),
("hls_segment_type", "fmp4"),
("hls_fmp4_init_filename", "init.mp4"),
]))
);
assert_eq!(output.video_codec.as_deref(), Some("libx264"));
assert!(output
.video_codec_opts
.as_ref()
.is_some_and(|codec_opts| codec_opts["g"] == "180"));
}
#[test]
fn master_version_follows_segment_type() {
assert_eq!(HlsSegmentType::MpegTs.master_playlist_version(), 3);
assert_eq!(HlsSegmentType::Fmp4.master_playlist_version(), 7);
}
#[test]
fn mpegts_master_text_is_frozen() {
let l = ladder();
let variants = l.build_master_variants(true).unwrap();
let text = generate_master_playlist(&variants, l.segment_type.master_playlist_version());
assert_eq!(
text,
"#EXTM3U\n\
#EXT-X-VERSION:3\n\
#EXT-X-STREAM-INF:BANDWIDTH=1020800,RESOLUTION=640x360\n\
360p/index.m3u8\n\
#EXT-X-STREAM-INF:BANDWIDTH=3220800,RESOLUTION=1280x720\n\
720p/index.m3u8\n"
);
}
#[test]
fn fmp4_master_text_declares_version_7() {
let l = ladder().segment_type(HlsSegmentType::Fmp4);
let variants = l.build_master_variants(true).unwrap();
let text = generate_master_playlist(&variants, l.segment_type.master_playlist_version());
assert!(text.starts_with("#EXTM3U\n#EXT-X-VERSION:7\n"));
}
#[cfg(windows)]
#[test]
fn path_to_utf8_normalizes_separators_for_ffmpeg() {
let p = std::path::Path::new(r"out\360p\index.m3u8");
assert_eq!(path_to_utf8(p).unwrap(), "out/360p/index.m3u8");
let p = std::path::Path::new(r"C:\hls\360p\index.m3u8");
assert_eq!(path_to_utf8(p).unwrap(), "C:/hls/360p/index.m3u8");
let p = std::path::Path::new(r"\\server\share\360p\index.m3u8");
assert_eq!(path_to_utf8(p).unwrap(), "//server/share/360p/index.m3u8");
let p = std::path::Path::new(r"\\?\C:\hls\360p\index.m3u8");
assert_eq!(path_to_utf8(p).unwrap(), r"\\?\C:\hls\360p\index.m3u8");
}