Merge codeaudit/tg-free-1 (3ac519d)

This commit is contained in:
Matthew Jackson
2026-09-29 20:17:05 -07:00
16 changed files with 483 additions and 45 deletions
+27
View File
@@ -335,6 +335,33 @@ mod tests {
);
}
#[test]
fn mpeg2_nb_fields_rff_on_interlaced_frame_is_two() {
// Spec-forbidden rff on a non-progressive interlaced frame: treated as 2.
for tff in [false, true] {
assert_eq!(
mpeg2(CodingType::P, tff, true, false, false, true).nb_fields(),
2
);
}
}
#[test]
fn mpeg2_field_picture_reports_pair_order_not_progressive() {
// Field picture: order comes from the stored pair order, even when the
// progressive flags are set.
for (prog_frame, prog_seq) in [(false, false), (true, false), (false, true)] {
assert_eq!(
mpeg2(CodingType::P, true, false, prog_frame, prog_seq, false).field_order(),
Some(FieldOrder::Tff)
);
assert_eq!(
mpeg2(CodingType::P, false, false, prog_frame, prog_seq, false).field_order(),
Some(FieldOrder::Bff)
);
}
}
#[test]
fn mpeg2_progressive_accessor() {
assert_eq!(
+36
View File
@@ -216,6 +216,42 @@ mod tests {
);
}
#[test]
fn pes_dts_used_when_pts_absent() {
let mut p = FlacParser::new();
let mut pes = make_pes(make_flac_frame(100), None);
pes.dts = Some(180_000);
let f = p.parse(&pes);
assert_eq!(f[0].pts_ns, pts_to_ns(180_000));
}
#[test]
fn pes_discontinuity_propagates_to_frame() {
let mut p = FlacParser::new();
let mut pes = make_pes(make_flac_frame(100), Some(90_000));
pes.discontinuity = true;
assert!(p.parse(&pes)[0].discontinuity);
assert!(!p.parse(&make_pes(make_flac_frame(100), Some(96_000)))[0].discontinuity);
}
#[test]
fn poisoned_track_drops_even_valid_frames() {
let mut p = FlacParser::new();
// A run of verified-corrupt frames poisons the track (200-AU verdict gate).
for i in 0..400 {
let mut bad = make_flac_frame(100);
bad[20] ^= 0xFF;
assert!(p.parse(&make_pes(bad, Some(i * 90))).is_empty());
}
assert!(p.tally.is_poisoned());
let before = p.dropped_frames();
assert!(
p.parse(&make_pes(make_flac_frame(100), Some(1_000_000)))
.is_empty()
);
assert_eq!(p.dropped_frames(), before + 1);
}
#[test]
fn non_flac_packet_passes_through() {
// A packet without the FLAC sync isn't a frame we can validate — never
+40 -7
View File
@@ -902,6 +902,36 @@ mod tests {
);
}
// The escaped slice header must be unescaped at the parse call site: a second slice with
// first_mb_in_slice=65535 (00 00 03 escape) is intra only when read unescaped.
#[test]
fn parse_unescapes_later_slice_header_before_reading_it() {
let au = vec![
0x00, 0x00, 0x01, 0x61, 0x88, 0x00, // slice 1: first_mb=0, I
0x00, 0x00, 0x01, 0x61, 0x00, 0x00, 0x03, 0x80, 0x00, 0x30, // slice 2: 65535, I
];
let mut parser = H264Parser::new();
let frames = parser.parse(&make_pes(au, Some(0)));
assert_eq!(frames.len(), 1);
assert!(
frames[0].keyframe,
"every slice reads intra only if the escape byte is removed"
);
}
// The gap flag on a PES must reach the frame, or the resync gate never arms for H.264.
#[test]
fn pes_discontinuity_propagates_to_frame() {
let au = vec![0x00, 0x00, 0x01, 0x61, 0x88, 0x00];
for flag in [true, false] {
let mut pes = make_pes(au.clone(), Some(0));
pes.discontinuity = flag;
let frames = H264Parser::new().parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].discontinuity, flag);
}
}
// Guard the promotion's precision: a non-intra (P) AU must NOT become a
// keyframe, or resync would resume on inter-referenced dropped data.
#[test]
@@ -1141,9 +1171,11 @@ mod tests {
for i in 0..30i64 {
let au = annexb(0x65, &vec![0x88u8; 300_000]);
let f = parser.parse(&make_pes(au, Some(3600 * (i + 1))));
// The re-assert really happened (otherwise the count is vacuously 0).
assert!(
f[0].data.len() > 300_000,
// The re-assert really happened (otherwise the count is vacuously 0):
// length-prefixed slice + SPS (4+1+body) + PPS (4+1+2).
assert_eq!(
f[0].data.len(),
(4 + 1 + 300_000) + (4 + 1 + big_sps.len()) + (4 + 1 + 2),
"keyframe {i} must carry the re-asserted parameter sets"
);
}
@@ -2205,8 +2237,9 @@ mod tests {
/// extension bytes.
#[test]
fn avcc_high_profile_extension_carries_correct_values() {
// profile_idc=100, chroma_format_idc=3 (4:4:4), depth_luma=2, depth_chroma=2.
let sps = build_high_profile_sps(100, 3, 2, 2);
// profile_idc=100, chroma_format_idc=3 (4:4:4), depth_luma=2, depth_chroma=5
// (unequal, so a luma/chroma swap shows).
let sps = build_high_profile_sps(100, 3, 2, 5);
let mut parser = H264Parser::new();
feed_sps_pps(&mut parser, &sps);
@@ -2217,8 +2250,8 @@ mod tests {
assert_eq!(cp[ext_off + 1] & 0x07, 2, "bit_depth_luma_minus8 must be 2");
assert_eq!(
cp[ext_off + 2] & 0x07,
2,
"bit_depth_chroma_minus8 must be 2"
5,
"bit_depth_chroma_minus8 must be 5"
);
assert_eq!(
cp[ext_off + 3],
+6 -1
View File
@@ -487,6 +487,11 @@ mod provenance_guard {
let mut i = 0;
while let Some(p) = src[i..].find("Frame {") {
let start = i + p;
// Skip longer names ending in `Frame {` (e.g. `PesFrame {`).
if start > 0 && (b[start - 1].is_ascii_alphanumeric() || b[start - 1] == b'_') {
i = start + "Frame {".len();
continue;
}
let open = start + src[start..].find('{').unwrap();
let (mut depth, mut k) = (0usize, open);
while k < b.len() {
@@ -518,7 +523,7 @@ mod provenance_guard {
// `source: None` fixtures are fine). Two loss spellings: explicit
// `source: None`, or omitting it (Frame derives Default, so `..` yields None).
let explicit_none = blk.contains("source: None");
let no_source_field = !blk.contains("source:");
let no_source_field = !blk.contains("source:") && !blk.contains("source,");
if explicit_none || no_source_field {
let line = src[..src.find(blk).unwrap_or(0)].lines().count() + 1;
let how = if explicit_none {
+24 -2
View File
@@ -1,13 +1,17 @@
use super::*;
// Spec quotes kept as reference text; the tests assert the behaviour directly.
/// 13818-3 (2nd ed.) §2.5.3.1, the rule the whole module follows.
#[allow(dead_code)]
const SPEC_DETECTION: &str = "The MPEG-1 ancillary data field is initially assumed to contain \
the coded multichannel extension. If the mandatory CRC-check yields a valid result, then \
multichannel decoding will be started.";
/// 13818-3 §2.5.1.3 (Layer II): the base frame's parts, in order.
#[allow(dead_code)]
const SPEC_BASE_FRAME: &str = "base_frame() { mpeg1_header() mpeg1_error_check() \
mpeg1_audio_data() mc_extension_data_part1() mpeg1_ancillary_data() }";
/// 13818-3 §2.5.2.14: what `mc_crc_check` covers.
#[allow(dead_code)]
const SPEC_MC_CRC: &str = "In Layer I and II, the calculation begins with the first bit of the \
multichannel header and ends with the last bit of the scfsi field, but excluding the \
mc_crc_check field itself.";
@@ -778,7 +782,6 @@ fn frame_crc_is_checked_when_protection_bit_is_zero() {
/// A frame whose mc_crc_check verifies is multichannel; one that does not, is not.
#[test]
fn multichannel_needs_a_valid_mc_crc_check() {
let _ = (SPEC_DETECTION, SPEC_BASE_FRAME, SPEC_MC_CRC);
let good = with_mc(STEREO_256, MC_3_2_LFE);
// §2.5.3.1: "If the mandatory CRC-check yields a valid result, then multichannel decoding
// will be started."
@@ -1044,6 +1047,25 @@ fn missing_channels_copy_their_allocation_as_the_tables_say() {
assert_eq!(src(0, 1, &st(1, 2, false), 0), "T1");
// 3/0 '0' "T2": transmitted.
assert_eq!(src(1, 0, &st(0, 0, false), 0), "read");
// 3/2 '0011' T2 missing across every tc: 1|7 from Lo, 2|6 from Ro, the rest by dyn_cross_LR.
for (tc, lr, want) in [
(7, true, "T0"),
(6, false, "T1"),
(4, false, "T0"),
(4, true, "T1"),
] {
assert_eq!(src(1, 2, &st(3, tc, lr), 0), want, "3/2 tc {tc} lr {lr}");
}
// 3/1 '011' T3 missing: tc 4|5 copy from Ro, else Lo unless tc<3 with dyn_cross_LR.
for (tc, lr, want) in [
(4, false, "T1"),
(5, false, "T1"),
(3, false, "T0"),
(7, true, "T0"),
(0, true, "T1"),
] {
assert_eq!(src(1, 1, &st(3, tc, lr), 1), want, "3/1 tc {tc} lr {lr}");
}
}
/// §2.5.2.13 centre "'11' centre bandwidth limited (Phantom coding)": "the subbands above
@@ -1459,7 +1481,7 @@ fn random_bytes_never_panic() {
let mut f: Vec<u8> = (0..len).map(|_| next() as u8).collect();
if i % 2 == 0 && len >= 4 {
f[0] = 0xFF;
f[1] = 0xFC | (f[1] & 0x03); // syncword, ID 1, Layer II
f[1] = 0xFC | (f[1] & 0x01); // syncword, ID 1, Layer II ('10')
}
if i % 3 == 0 {
f = seeds[i % seeds.len()].clone();
+74 -4
View File
@@ -1358,16 +1358,15 @@ mod tests {
// --- parameter-set-only stream: seq header, no picture → no frame ---
#[test]
fn sequence_header_only_emits_no_frame_but_captures_codec_private() {
fn sequence_header_only_emits_no_frame_and_captures_no_codec_private() {
let mut parser = Mpeg2Parser::new();
let mut data = make_seq_header(1920, 1080, 3, 4);
data.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE, 0x14, 0x8A]);
// No picture start code at all.
let frames = parse_then_flush(&mut parser, &make_pes(data, Some(0)));
assert!(frames.is_empty(), "no coded picture → no frame");
// codec_private only captured when an AU is emitted; a header-only
// stream emits nothing, so nothing is captured — and there is no frame
// to need it. (Real streams always follow the header with a picture.)
// A header-only AU returns before extract_seq_header: nothing is captured.
assert!(parser.codec_private().is_none());
}
// --- buffer cap: corrupt stream with no second boundary is force-flushed ---
@@ -1387,6 +1386,77 @@ mod tests {
assert!(frames[0].keyframe);
}
// Frame-count cap: a run of pictures with no GOP/sequence boundary is force-flushed
// during parse at MAX_PENDING_FRAMES, not held until EOF.
#[test]
fn gop_frame_cap_flushes_during_parse() {
let mut parser = Mpeg2Parser::new();
let mut data = Vec::new();
for _ in 0..MAX_PENDING_FRAMES + 100 {
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend_from_slice(&[0xAA; 4]);
}
let out = parser.parse(&make_pes(data, Some(0)));
assert_eq!(out.len(), MAX_PENDING_FRAMES, "cap flushed one full run");
}
// Byte cap: few-but-huge pictures are force-flushed once the buffered bytes reach
// MAX_PENDING_BYTES, even far below the frame cap.
#[test]
fn gop_byte_cap_flushes_during_parse() {
let mut parser = Mpeg2Parser::new();
let mut data = Vec::new();
for _ in 0..2 {
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend(std::iter::repeat_n(0xAA, MAX_PENDING_BYTES / 2));
}
data.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
data.extend_from_slice(&[0xAA; 4]);
let out = parser.parse(&make_pes(data, Some(0)));
assert_eq!(out.len(), 2, "byte cap flushed the two huge pictures");
}
// Parser-level timing for a progressive sequence: rff counts 4 fields (tff=0) or 6 (tff=1)
// only when the sequence extension's progressive_sequence is honoured.
#[test]
fn progressive_sequence_rff_durations_via_parser() {
let mut parser = Mpeg2Parser::new();
let field = 20_000_000u64; // 25 fps
let mut au = make_seq_header(720, 576, 3, 3);
au.extend_from_slice(&[0x00, 0x00, 0x01, SEQ_EXT_CODE, 0x10, 0x08]); // progressive_sequence
au.extend_from_slice(&gop());
for (ct, tr, tff) in [(1u8, 0u16, 0u8), (2, 1, 1)] {
au.extend_from_slice(&make_picture_header_tr(ct, tr));
au.extend_from_slice(&pic_coding_ext(tff, 1, 0, true));
au.extend_from_slice(&[0xAA; 8]);
}
let f = parse_then_flush(&mut parser, &make_pes(au, Some(0)));
assert_eq!(f.len(), 2);
assert_eq!(f[0].duration_ns, Some(4 * field), "rff, tff=0 → 4 fields");
assert_eq!(f[1].duration_ns, Some(6 * field), "rff, tff=1 → 6 fields");
assert_eq!(f[1].pts_ns as u64, 4 * field);
}
// Parser-level timing for field pictures: each occupies one field period.
#[test]
fn field_pictures_occupy_one_field_via_parser() {
let mut parser = Mpeg2Parser::new();
let field = 20_000_000u64; // 25 fps
let mut au = make_seq_header(720, 576, 3, 3);
au.extend_from_slice(&gop());
for (ct, e2) in [(1u8, 0x01u8), (2, 0x02)] {
au.extend_from_slice(&make_picture_header(ct));
let mut ext = pic_coding_ext(0, 0, 0, false);
ext[6] = e2;
au.extend_from_slice(&ext);
au.extend_from_slice(&[0xAA; 8]);
}
let f = parse_then_flush(&mut parser, &make_pes(au, Some(0)));
assert_eq!(f.len(), 2);
assert_eq!(f[0].duration_ns, Some(field));
assert_eq!(f[1].duration_ns, Some(field));
}
// --- parse_resolution: 12-bit field packing (ISO 13818-2 §6.2.2.1) ---
#[test]
+34
View File
@@ -376,4 +376,38 @@ mod tests {
sorted.dedup();
assert_eq!(sorted.len(), all.len(), "no two frames share a display PTS");
}
#[test]
fn unanchored_gops_continue_after_the_previous_gop() {
use CodingType::*;
// GOPs 1-2 carry anchors (calibrating dur); GOPs 3-4 carry none and must
// continue at origin + count*dur, not collide.
let dur = 40_000_000i64;
let mut r = SparsePtsReorder::new();
let mut got: Vec<i64> = Vec::new();
for anchor in [Some(0i64), Some(3 * dur), None, None] {
for (k, ct) in [I, P, P].into_iter().enumerate() {
let p = if k == 0 { anchor } else { None };
got.extend(r.push(p, frame(ct, k == 0)).iter().map(|f| f.pts_ns));
}
}
got.extend(r.flush().iter().map(|f| f.pts_ns));
let want: Vec<i64> = (0..12).map(|i| i * dur).collect();
assert_eq!(got, want);
}
#[test]
fn single_anchor_uses_fallback_duration() {
use CodingType::*;
let mut r = SparsePtsReorder::new();
let mut got = Vec::new();
for (k, ct) in [I, P, P].into_iter().enumerate() {
got.extend(r.push((k == 0).then_some(1_000), frame(ct, k == 0)));
}
got.extend(r.flush());
let pts: Vec<i64> = got.iter().map(|f| f.pts_ns).collect();
let d = FALLBACK_FRAME_DUR_NS;
assert_eq!(pts, vec![1_000, 1_000 + d, 1_000 + 2 * d]);
assert!(got.iter().all(|f| f.duration_ns == Some(d as u64)));
}
}
+8
View File
@@ -327,4 +327,12 @@ mod tests {
let data = [0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00];
assert_eq!(BitReader::new(&data).read_ue(), Some(u32::MAX >> 1));
}
#[test]
fn read_ue_thirty_two_leading_zeros_is_rejected() {
// 32 zeros, a stop bit, 32 info bits: one past the cap, so None (not a code
// whose `1 << 32` would overflow).
let data = [0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00];
assert_eq!(BitReader::new(&data).read_ue(), None);
}
}
+33 -1
View File
@@ -359,12 +359,17 @@ mod tests {
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4);
let (rc_tx, _rc_rx) = bounded::<Vec<u8>>(4);
let halt = Halt::new();
// Without the close the worker only sees the cancel at its loop-top check, and
// it blocks in recv() once past it, so raise the flag before it can start.
if !close_first {
halt.cancel();
}
let ts = super::super::ts::TsDemuxer::new(&[0x1011]);
let (dt, rx) =
DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), Some(halt.clone()), Some(ts), None)
.unwrap();
halt.cancel();
if close_first {
halt.cancel();
drop(pf_tx);
let b = collect_batches(&rx, Duration::from_secs(5));
drop(rx);
@@ -388,6 +393,33 @@ mod tests {
}
}
#[test]
fn ts_pes_source_offsets_advance_across_buffers() {
// Each buffer is fed at the running byte offset, so a PES cut from the second
// buffer is stamped 192, not 0.
let (pf_tx, pf_rx) = bounded::<std::io::Result<Vec<u8>>>(4);
let (rc_tx, _rc_rx) = bounded::<Vec<u8>>(4);
let pid = 0x1011;
let ts = super::super::ts::TsDemuxer::new(&[pid]);
let (_dt, rx) =
DemuxThread::spawn_zero_copy(pf_rx, rc_tx, (), None, Some(ts), None).unwrap();
pf_tx.send(Ok(bdts_pes_packet(pid, &[0x01]))).unwrap();
pf_tx.send(Ok(bdts_pes_packet(pid, &[0x02]))).unwrap();
drop(pf_tx);
let batches = collect_batches(&rx, Duration::from_secs(5));
let srcs: Vec<u64> = batches
.iter()
.filter_map(|b| match b {
DemuxBatch::Ts(p) => Some(p),
_ => None,
})
.flatten()
.map(|p| p.source.expect("stamped").byte)
.collect();
assert_eq!(srcs, vec![0, 192]);
}
#[test]
fn upstream_error_is_propagated_as_err_terminal() {
// An error from the prefetch channel must be forwarded as a terminal
+1 -1
View File
@@ -598,7 +598,7 @@ mod tests {
let minf = child(&mdia[8..], b"minf").expect("minf");
// ── mdhd. Version 0 layout: vflags(4) creation(4) modification(4)
// timescale(4) duration(4) language(2) pre_defined(2) = 20 bytes.
// timescale(4) duration(4) language(2) pre_defined(2) = 24 bytes.
let mdhd = child(&mdia[8..], b"mdhd").expect("mdia must carry mdhd");
let body = &mdhd[8..];
assert_eq!(body[0], 0, "mdhd version 0 (32-bit times)");
+36 -27
View File
@@ -214,17 +214,6 @@ mod tests {
use crate::mux::codec::coding::{CodingType, Mpeg2Coding};
use crate::mux::videomap::Medium;
use crate::pes::SourcePos;
use std::path::PathBuf;
/// Tiny unique temp dir helper (avoids a dev-dependency on `tempfile`).
fn tempdir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let p = std::env::temp_dir().join(format!("fmkv_fvi_test_{}_{}", std::process::id(), n));
std::fs::create_dir_all(&p).unwrap();
p
}
fn mpeg2_title() -> DiscTitle {
let mut t = DiscTitle::empty();
@@ -301,18 +290,21 @@ mod tests {
#[test]
fn sink_is_write_only() {
let dir = tempdir();
let mut sink =
FviSink::create(&dir.join("x.fvi"), &mpeg2_title(), SourceInfo::default()).unwrap();
let dir = tempfile::tempdir().unwrap();
let mut sink = FviSink::create(
&dir.path().join("x.fvi"),
&mpeg2_title(),
SourceInfo::default(),
)
.unwrap();
let err = Stream::read(&mut sink).expect_err("read must error");
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sink_writes_header_and_only_video_records() {
let dir = tempdir();
let path = dir.join("movie.fvi");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("movie.fvi");
let mut sink = FviSink::create(
&path,
&mpeg2_title(),
@@ -366,27 +358,46 @@ mod tests {
rec.get("gop").is_none(),
"no GOP-closure signal → gop omitted"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn secondary_video_stream_is_not_indexed() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pip.fvi");
let mut title = mpeg2_title();
let mut second = title.streams[0].clone();
if let DiscStream::Video(v) = &mut second {
v.pid = 0x1012;
v.secondary = true;
}
title.streams.push(second);
let mut sink = FviSink::create(&path, &title, SourceInfo::default()).unwrap();
sink.write(&vframe(0, Some(i_pic()), Some(SourcePos::at_byte(0))))
.unwrap();
sink.write(&vframe(1, Some(i_pic()), Some(SourcePos::at_byte(2048))))
.unwrap();
sink.finish().unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 2, "header + primary record only");
}
#[test]
fn empty_title_still_emits_valid_header() {
let dir = tempdir();
let path = dir.join("empty.fvi");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.fvi");
let mut sink = FviSink::create(&path, &mpeg2_title(), SourceInfo::default()).unwrap();
sink.finish().unwrap(); // no frames
let text = std::fs::read_to_string(&path).unwrap();
assert_eq!(text.lines().count(), 1, "header only");
let header: serde_json::Value = serde_json::from_str(text.lines().next().unwrap()).unwrap();
assert_eq!(header["format"], "freemkv/video-index");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn extension_jsonl_is_still_json_lines() {
// Output is always JSON Lines regardless of extension (one format today).
let dir = tempdir();
let path = dir.join("idx.jsonl");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("idx.jsonl");
let mut sink = FviSink::create(&path, &mpeg2_title(), SourceInfo::default()).unwrap();
sink.write(&vframe(0, Some(i_pic()), None)).unwrap();
sink.finish().unwrap();
@@ -394,7 +405,6 @@ mod tests {
// null src on a provenance-absent frame.
let rec: serde_json::Value = serde_json::from_str(text.lines().nth(1).unwrap()).unwrap();
assert_eq!(rec["src"], serde_json::Value::Null);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
@@ -402,8 +412,8 @@ mod tests {
// A non-MPEG2 stream (coding None) whose parser sets keyframe must still
// produce USEFUL records: key/type from the frame keyframe flag, src +
// pts populated, and NO mpeg2-only field members.
let dir = tempdir();
let path = dir.join("uhd.fvi");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("uhd.fvi");
let mut sink = FviSink::create(
&path,
&hevc_title(),
@@ -438,6 +448,5 @@ mod tests {
assert_eq!(recs[1]["key"], false);
assert_eq!(recs[1]["type"], "P");
assert_eq!(recs[1]["src"]["sector"], 10); // 20480 / 2048
let _ = std::fs::remove_dir_all(&dir);
}
}
+23
View File
@@ -268,4 +268,27 @@ mod tests {
}));
assert!(!g.ready(&t, |_| None));
}
#[test]
fn secondary_video_never_gates() {
let mut t = DiscTitle::empty();
for (pid, secondary) in [(0x1011, false), (0x1012, true)] {
t.streams.push(Stream::Video(crate::disc::VideoStream {
pid,
codec: Codec::Hevc,
resolution: crate::disc::Resolution::R2160p,
frame_rate: crate::disc::FrameRate::F23_976,
hdr: crate::disc::HdrFormat::Hdr10,
color_space: crate::disc::ColorSpace::Bt2020,
display_aspect: None,
secondary,
label: String::new(),
measured_cicp: None,
}));
}
let g = HeaderGate::default();
// Only the primary's config is awaited; the PiP / DV-EL track has none.
assert!(g.ready(&t, |i| (i == 0).then(|| vec![1])));
assert!(!g.ready(&t, |i| (i == 1).then(|| vec![1])));
}
}
+6
View File
@@ -462,6 +462,12 @@ mod tests {
let mut demux = crate::mux::ts::TsDemuxer::new(&[AUDIO_PID]);
let mut pes = demux.feed(ts);
pes.extend(demux.flush());
// Each 5 ms BD sub-payload carries its own offset PTS, so none repeat.
let pts: Vec<i64> = pes.iter().map(|p| p.pts.expect("PES PTS")).collect();
assert!(
pts.windows(2).all(|w| w[0] < w[1]),
"{channels:?} x{samples}: PES PTS strictly increasing, got {pts:?}"
);
let mut parser = crate::mux::codec::lpcm::LpcmParser::new();
let got: Vec<u8> = pes
.iter()
+106
View File
@@ -561,4 +561,110 @@ mod tests {
assert_eq!(sample_rates(SampleRate::S48_192), vec![48_000, 192_000]);
assert!(sample_rates(SampleRate::Unknown).is_empty());
}
/// Two streams of each kind, interleaved: every per-kind cursor must advance,
/// or the second stream's fields land on the first's entry.
#[test]
fn json_per_kind_cursors_and_title_fields() {
use crate::disc::{
AudioChannels, AudioStream, Clip, Codec, ColorSpace, ContentFormat, DiscTitle,
FrameRate, HdrFormat, LabelPurpose, LabelQualifier, Resolution, SampleRate,
Stream as DiscStream, SubtitleStream, VideoStream,
};
let vid = |pid| {
DiscStream::Video(VideoStream {
pid,
codec: Codec::Hevc,
resolution: Resolution::R2160p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt2020,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
})
};
let aud = |pid| {
DiscStream::Audio(AudioStream {
pid,
codec: Codec::Ac3,
channels: AudioChannels::Stereo,
language: "eng".into(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
})
};
let sub = |pid| {
DiscStream::Subtitle(SubtitleStream {
pid,
codec: Codec::Pgs,
language: "eng".into(),
forced: false,
qualifier: LabelQualifier::None,
codec_data: None,
})
};
let mut t = DiscTitle::empty();
t.streams = vec![
aud(0x1100),
vid(0x1011),
aud(0x1101),
sub(0x1200),
sub(0x1201),
vid(0x1012),
];
t.content_format = ContentFormat::BdTs;
t.playlist_id = 801;
t.clips = vec![Clip {
feed_span: None,
clip_id: "00007".into(),
in_time: 0,
out_time: 45_000,
duration_secs: 1.0,
source_packets: 42,
}];
let v = title_json(&t);
for (kind, pids) in [
("video", [0x1011, 0x1012]),
("audio", [0x1100, 0x1101]),
("subtitles", [0x1200, 0x1201]),
] {
assert_eq!(v[kind][0]["pid"], pids[0], "{kind}[0]");
assert_eq!(v[kind][1]["pid"], pids[1], "{kind}[1]");
}
assert_eq!(v["format"], "BdTs");
assert_eq!(v["playlist_id"], 801);
assert_eq!(v["clips"][0]["clip_id"], "00007");
assert_eq!(v["clips"][0]["source_packets"], 42);
assert_eq!(v["clips"][0]["duration_secs"], 1.0);
}
/// create() output read back: the extension selects the chapter format and
/// the JSON file holds the title document.
#[test]
fn chapters_and_json_create_write_readable_files() {
let dir = tempfile::tempdir().unwrap();
let mut t = DiscTitle::empty();
t.chapters = chaps();
t.playlist = "MAIN".into();
for (name, marker) in [
("c.txt", "CHAPTER01="),
("c.VTT", "WEBVTT"),
("c.xml", "<Chapters>"),
] {
let path = dir.path().join(name);
ChaptersSink::create(&path, &t).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains(marker), "{name}: {text}");
}
let path = dir.path().join("t.json");
JsonSink::create(&path, &t).unwrap();
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(v["playlist"], "MAIN");
assert_eq!(v["chapter_marks"][1]["n"], 2);
}
}
+3 -2
View File
@@ -197,13 +197,14 @@ mod tests {
}
#[test]
fn is_disc_source_only_for_disc_and_iso() {
fn is_disc_source_only_for_disc_iso_and_dir() {
// is_disc_source gates the "raw sector copy" path. Per the doc table
// only disc:// and iso:// are disc sources; mkv/m2ts/network/etc must
// only disc://, iso:// and dir:// are disc sources; mkv/m2ts/network/etc must
// NOT be (they are container/stream formats, not raw sector media).
assert!(parse_url("disc://").is_disc_source());
assert!(parse_url("disc:///dev/sg1").is_disc_source());
assert!(parse_url("iso://x.iso").is_disc_source());
assert!(parse_url("dir://x").is_disc_source());
assert!(!parse_url("m2ts://x").is_disc_source());
assert!(!parse_url("mkv://x").is_disc_source());
assert!(!parse_url("network://h:1").is_disc_source());
+26
View File
@@ -353,6 +353,32 @@ mod tests {
);
}
// Pass 1 keeps a source sub-id only inside the codec's own range and only once: an AC-3
// track under a DTS-range id is re-allocated, and a duplicate id takes the lowest free.
#[test]
fn source_sub_ids_out_of_range_or_duplicated_are_reallocated() {
let t = title(vec![
video(Codec::Mpeg2),
audio(0xBD89, Codec::Ac3, SampleRate::S48, ""),
audio(0xBD81, Codec::Ac3, SampleRate::S48, ""),
audio(0xBD81, Codec::Ac3, SampleRate::S48, ""),
]);
let p = plan(&t);
let ac3 = |sub_id| {
Some(Carriage::Private {
sub_id,
kind: PrivateKind::Ac3,
})
};
assert_eq!(p.carriage[1], ac3(0x80), "0x89 is DTS range, not kept");
assert_eq!(p.carriage[2], ac3(0x81), "first 0x81 keeps its id");
assert_eq!(
p.carriage[3],
ac3(0x82),
"duplicate 0x81 takes the lowest free id"
);
}
// J23: a declared extension whose base is not carried is in neither half (seen-only).
#[test]
fn an_extension_without_a_carried_base_is_not_planned_out() {