Add 113 tests, update CI to checkout@v5, add FEATURES.md

Test suite: 64 → 177 tests
- MPLS parser: 6 tests (synthetic binary, streams, errors)
- CLPI parser: 6 tests (EP map, PTS/SPN math, errors)
- H.264: 12 tests (NAL parsing, SPS/PPS, keyframes)
- HEVC: 13 tests (VPS/SPS/PPS, IRAP range, codec private)
- AC3: 12 tests (syncword, frame extraction)
- VC1: 15 tests (BITMAPINFOHEADER, start codes)
- DTS: 5, TrueHD: 4, PGS: 4 tests
- EBML: 6 tests (size/ID/string/float roundtrips)
- UDF: 10 tests (MockSectorReader, filesystem parsing, error paths)
- Disc: 8 tests (scan_image, DiscTitle helpers)
- Streams: 5 new (meta roundtrip, MkvStream)
- NullStream: 4, StdioStream: 2, IsoSectorReader: 2

CI: actions/checkout@v4 → v5 (all workflows)
FEATURES.md: created for v0.7.1
This commit is contained in:
MattJackson
2026-04-11 16:02:49 +00:00
parent dc4ebd7d9b
commit 995525d3ff
20 changed files with 2639 additions and 7 deletions
+247
View File
@@ -268,3 +268,250 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
Ok((ep_coarse, ep_fine))
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a minimal CLPI binary.
/// `cpi_data` is the raw CPI section bytes (starting with the 4-byte CPI length).
fn build_clpi(source_packet_count: u32, cpi_data: Option<&[u8]>) -> Vec<u8> {
// We need at least 60 bytes for the header area.
// Offsets:
// 0..4: "HDMV"
// 4..8: "0200"
// 8..12: seq_info_start (unused, set to 0)
// 12..16: prog_info_start (unused, set to 0)
// 16..20: cpi_start
// 20..40: reserved/padding
// 40..56: ClipInfo section area (length + stuff before source_packet_count)
// 56..60: source_packet_count
let cpi_start: u32 = if cpi_data.is_some() { 60 } else { 0 };
let mut buf = vec![0u8; 60];
// Magic + version
buf[0..4].copy_from_slice(b"HDMV");
buf[4..8].copy_from_slice(b"0200");
// seq_info_start = 0
// prog_info_start = 0
// cpi_start
buf[16..20].copy_from_slice(&cpi_start.to_be_bytes());
// source_packet_count at offset 56
buf[56..60].copy_from_slice(&source_packet_count.to_be_bytes());
if let Some(cpi) = cpi_data {
buf.extend_from_slice(cpi);
}
buf
}
/// Build a CPI section with one stream's EP map.
/// coarse_entries: Vec<(ref_to_fine_id, pts_coarse, spn_coarse)>
/// fine_entries: Vec<(pts_fine, spn_fine)>
fn build_cpi(
stream_pid: u16,
coarse_entries: &[(u32, u32, u32)],
fine_entries: &[(u32, u32)],
) -> Vec<u8> {
// CPI section layout:
// [0..4] cpi_length (u32 BE)
// [4..6] reserved/type (2 bytes)
// [6..] EP map
//
// EP map layout (relative to byte 6 of CPI):
// [0] reserved
// [1] num_streams (1)
// [2..4] stream_PID (u16 BE)
// [4..14] 80 bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32)
// [14..] (next stream entry, if any)
//
// Stream EP map (at EP_map_start relative to EP map start):
// [0..4] fine_start (relative to stream EP map start)
// [4..] coarse entries, 8 bytes each
// [fine_start..] fine entries, 4 bytes each
let num_coarse = coarse_entries.len() as u32;
let num_fine = fine_entries.len() as u32;
// EP_map_start: offset from ep_map start where the stream EP data begins.
// ep_map has: reserved(1) + num_streams(1) + stream_header(12) = 14 bytes
// So EP_map_start = 14 (first stream data right after the header)
let ep_map_start: u32 = 14;
// Build the 80-bit stream PID entry (10 bytes: ep_map[4..14])
// Bits: reserved(10) + EP_stream_type(4) + num_coarse(16) + num_fine(18) + EP_map_start(32)
// Total: 80 bits = 10 bytes
//
// Pack into a u128 for convenience then extract 10 bytes
let ep_stream_type: u32 = 1; // video
let packed: u128 = ((0u128) << 70) // reserved: 10 bits
| ((ep_stream_type as u128) << 66) // EP_stream_type: 4 bits
| ((num_coarse as u128) << 50) // num_coarse: 16 bits
| ((num_fine as u128) << 32) // num_fine: 18 bits
| (ep_map_start as u128); // EP_map_start: 32 bits
let packed_bytes = packed.to_be_bytes(); // 16 bytes, we want the last 10
let stream_header_bits = &packed_bytes[6..16];
// Build stream EP data
// fine_start = 4 (header) + num_coarse * 8
let fine_start: u32 = 4 + num_coarse * 8;
let mut stream_ep = Vec::new();
stream_ep.extend_from_slice(&fine_start.to_be_bytes());
// Coarse entries: 8 bytes each
// dword0 = (ref_to_fine_id << 14) | (pts_coarse & 0x3FFF)
// dword1 = spn_coarse
for &(ref_id, pts_c, spn_c) in coarse_entries {
let dword0 = (ref_id << 14) | (pts_c & 0x3FFF);
stream_ep.extend_from_slice(&dword0.to_be_bytes());
stream_ep.extend_from_slice(&spn_c.to_be_bytes());
}
// Fine entries: 4 bytes each
// dword = (is_angle(1) + i_end_offset(3) + pts_fine(11) + spn_fine(17))
for &(pts_f, spn_f) in fine_entries {
let dword: u32 = ((pts_f & 0x7FF) << 17) | (spn_f & 0x1FFFF);
stream_ep.extend_from_slice(&dword.to_be_bytes());
}
// Assemble EP map
let mut ep_map = Vec::new();
ep_map.push(0); // reserved
ep_map.push(1); // num_streams = 1
ep_map.extend_from_slice(&stream_pid.to_be_bytes());
ep_map.extend_from_slice(stream_header_bits);
ep_map.extend_from_slice(&stream_ep);
// Assemble CPI section
let mut cpi = Vec::new();
let cpi_length = (2 + ep_map.len()) as u32; // reserved/type(2) + ep_map
cpi.extend_from_slice(&cpi_length.to_be_bytes());
cpi.extend_from_slice(&[0u8; 2]); // reserved/type
cpi.extend_from_slice(&ep_map);
cpi
}
#[test]
fn parse_valid_clpi() {
let cpi = build_cpi(
0x1011,
&[(0, 100, 0x00020000)], // 1 coarse
&[(50, 1024)], // 1 fine
);
let data = build_clpi(500_000, Some(&cpi));
let clip = parse(&data).expect("should parse valid CLPI");
assert_eq!(clip.version, "0200");
assert_eq!(clip.source_packet_count, 500_000);
assert_eq!(clip.ep_coarse.len(), 1);
assert_eq!(clip.ep_fine.len(), 1);
}
#[test]
fn parse_ep_map() {
let cpi = build_cpi(
0x1011,
&[
(0, 100, 0x00020000), // coarse 0: fine starts at 0, pts_coarse=100, spn_coarse=0x20000
(2, 200, 0x00040000), // coarse 1: fine starts at 2, pts_coarse=200, spn_coarse=0x40000
],
&[
(50, 1024), // fine 0
(100, 2048), // fine 1
(25, 512), // fine 2
(75, 1536), // fine 3
],
);
let data = build_clpi(1_000_000, Some(&cpi));
let clip = parse(&data).expect("should parse EP map");
assert_eq!(clip.ep_coarse.len(), 2);
assert_eq!(clip.ep_fine.len(), 4);
// Verify coarse entries
assert_eq!(clip.ep_coarse[0].ref_to_fine_id, 0);
assert_eq!(clip.ep_coarse[0].pts_coarse, 100);
assert_eq!(clip.ep_coarse[0].spn_coarse, 0x00020000);
assert_eq!(clip.ep_coarse[1].ref_to_fine_id, 2);
assert_eq!(clip.ep_coarse[1].pts_coarse, 200);
assert_eq!(clip.ep_coarse[1].spn_coarse, 0x00040000);
// Verify fine entries
assert_eq!(clip.ep_fine[0].pts_fine, 50);
assert_eq!(clip.ep_fine[0].spn_fine, 1024);
assert_eq!(clip.ep_fine[1].pts_fine, 100);
assert_eq!(clip.ep_fine[1].spn_fine, 2048);
assert_eq!(clip.ep_fine[2].pts_fine, 25);
assert_eq!(clip.ep_fine[2].spn_fine, 512);
assert_eq!(clip.ep_fine[3].pts_fine, 75);
assert_eq!(clip.ep_fine[3].spn_fine, 1536);
// Verify resolved EP map assigns fine entries to coarse correctly
let resolved = clip.resolved_ep_map();
assert_eq!(resolved.len(), 4);
// First two fines belong to coarse 0, last two to coarse 1
}
#[test]
fn full_pts_calculation() {
let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 100, spn_coarse: 0 };
let fine = EpFine { pts_fine: 50, spn_fine: 0 };
// full_pts = (100 << 19) + (50 << 8) = 52_428_800 + 12_800 = 52_441_600
let pts = ClipInfo::full_pts(&coarse, &fine);
assert_eq!(pts, (100 << 19) + (50 << 8));
assert_eq!(pts, 52_441_600);
}
#[test]
fn full_spn_calculation() {
let coarse = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FE0000 };
let fine = EpFine { pts_fine: 0, spn_fine: 0x1234 };
// full_spn = (0x00FE0000 & 0xFFFE0000) + 0x1234 = 0x00FE0000 + 0x1234 = 0x00FE1234
let spn = ClipInfo::full_spn(&coarse, &fine);
assert_eq!(spn, 0x00FE0000 + 0x1234);
assert_eq!(spn, 0x00FE1234);
// Test that the low bit of spn_coarse is masked out
let coarse2 = EpCoarse { ref_to_fine_id: 0, pts_coarse: 0, spn_coarse: 0x00FF0000 };
let spn2 = ClipInfo::full_spn(&coarse2, &fine);
// 0x00FF0000 & 0xFFFE0000 = 0x00FE0000, so low 17 bits of coarse are zeroed
assert_eq!(spn2, 0x00FE0000 + 0x1234);
}
#[test]
fn parse_invalid_magic() {
let mut data = build_clpi(1000, None);
data[0] = b'X';
data[1] = b'X';
data[2] = b'X';
data[3] = b'X';
assert!(parse(&data).is_err());
}
#[test]
fn parse_empty_ep_map() {
// cpi_start = 0 means no CPI section
let data = build_clpi(100_000, None);
let clip = parse(&data).expect("should parse with no EP map");
assert_eq!(clip.source_packet_count, 100_000);
assert!(clip.ep_coarse.is_empty());
assert!(clip.ep_fine.is_empty());
// Also test: CPI section present but with zero streams
let mut cpi = Vec::new();
let cpi_length: u32 = 6; // reserved/type(2) + ep_map(reserved(1) + num_streams=0(1) + 2 padding)
cpi.extend_from_slice(&cpi_length.to_be_bytes());
cpi.extend_from_slice(&[0u8; 2]); // reserved/type
cpi.push(0); // reserved
cpi.push(0); // num_streams = 0
cpi.extend_from_slice(&[0u8; 4]); // padding
let data2 = build_clpi(100_000, Some(&cpi));
let clip2 = parse(&data2).expect("should parse with zero-stream EP map");
assert!(clip2.ep_coarse.is_empty());
assert!(clip2.ep_fine.is_empty());
}
}