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
+2 -2
View File
@@ -9,13 +9,13 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo test
check-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo check
+4 -4
View File
@@ -12,7 +12,7 @@ jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Verify Cargo.toml version matches tag
run: |
CARGO_VER="v$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')"
@@ -26,7 +26,7 @@ jobs:
needs: verify
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- run: cargo test
@@ -34,7 +34,7 @@ jobs:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- name: Publish to crates.io
run: cargo publish
@@ -45,7 +45,7 @@ jobs:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
ref: main
token: ${{ secrets.ORG_DISPATCH_TOKEN }}
+30
View File
@@ -0,0 +1,30 @@
# libfreemkv — Feature List
## v0.7.1 (current)
### Done
- [x] Drive access: open, identify, unlock, firmware upload, speed calibration, eject
- [x] 206 bundled drive profiles (MediaTek MT1959 A + B variants)
- [x] SCSI transport: Linux SG_IO, macOS IOKit
- [x] UDF 2.50 filesystem parser (metadata partitions, Blu-ray profile)
- [x] MPLS playlist parser (play items, STN table, secondary streams)
- [x] CLPI clip info parser (EP map, sector extents)
- [x] AACS 1.0 decryption (4 VUK paths: KEYDB, media key, processing key, device key)
- [x] AACS 2.0 SCSI handshake (P-256/SHA-256 ECDH, bus decryption, read data key)
- [x] KEYDB.cfg download, verify, save (raw TCP, zero HTTP deps)
- [x] Content reading with adaptive batch sizing, error recovery, 12+ MB/s
- [x] Stream labels: 5 BD-J format parsers (Paramount, Criterion, Pixelogic, CTRM, Deluxe)
- [x] MKV muxer: 8 codec parsers (H.264, HEVC, AC-3, DTS, TrueHD, PGS, VC-1, LPCM)
- [x] SectorReader trait: decouples disc scanning from SCSI
- [x] 7 stream types: Disc, ISO, MKV, M2TS, Network, Stdio, Null
- [x] IOStream trait with URL-based resolver (scheme://path)
- [x] FMKV metadata header for M2TS and network streams
- [x] Numeric error codes only (no English text in library)
- [x] Event system for progress callbacks
### Planned
- [ ] Windows SCSI transport (SPTI)
- [ ] Pioneer Renesas platform support (48 drives, need GET_CONFIG 010C)
- [ ] DVD CSS decryption
- [ ] TranscodeStream (ffmpeg integration)
- [ ] ISO write with BD-compliant UDF structure
+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());
}
}
+305
View File
@@ -321,3 +321,308 @@ fn parse_stream_entry(item: &[u8], pos: usize, stream_type: u8) -> Option<(Strea
secondary: false,
}, sa_end))
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a minimal MPLS binary with given play items and STN streams on the first item.
/// STN counts: (n_video, n_audio, n_pg, n_ig, n_sec_audio, n_sec_video, n_pip_pg, n_dv)
fn build_mpls(
play_items_data: &[(/*clip_id*/&[u8;5], /*conn*/u8, /*in_time*/u32, /*out_time*/u32)],
stn_counts: (u8, u8, u8, u8, u8, u8, u8, u8),
stream_entries: &[Vec<u8>], // raw stream entry + attributes bytes for each stream
) -> Vec<u8> {
let playlist_start: u32 = 40; // right after the 40-byte header
let mut buf = Vec::new();
// File header: "MPLS" + version + playlist_start + 3 more offsets (unused)
buf.extend_from_slice(b"MPLS0200");
buf.extend_from_slice(&playlist_start.to_be_bytes());
// mark_start, extension_start (unused by parser), padding to 40 bytes
buf.extend_from_slice(&[0u8; 28]);
// PlayList section starts here (offset 40)
// PlayList: length(4) + reserved(2) + num_play_items(2) + num_sub_paths(2) = 10 header bytes
let pl_start = buf.len();
buf.extend_from_slice(&[0u8; 4]); // length placeholder
buf.extend_from_slice(&[0u8; 2]); // reserved
buf.extend_from_slice(&(play_items_data.len() as u16).to_be_bytes());
buf.extend_from_slice(&[0u8; 2]); // num_sub_paths
for (idx, (clip_id, conn, in_time, out_time)) in play_items_data.iter().enumerate() {
// Build play item content
let mut item = Vec::new();
// [0..5] clip_id
item.extend_from_slice(*clip_id);
// [5..9] codec_id ("M2TS")
item.extend_from_slice(b"M2TS");
// [9] connection_condition in low nibble
item.push(*conn & 0x0F);
// [10..12] reserved
item.extend_from_slice(&[0u8; 2]);
// [12..16] in_time
item.extend_from_slice(&in_time.to_be_bytes());
// [16..20] out_time
item.extend_from_slice(&out_time.to_be_bytes());
// [20..28] UO_mask_table
item.extend_from_slice(&[0u8; 8]);
// [28] misc flags
item.push(0);
// [29] still_mode
item.push(0);
// [30..32] still_time
item.extend_from_slice(&[0u8; 2]);
// STN table (only for the first play item)
if idx == 0 {
// STN header: length(2) + reserved(2) + counts(8) + reserved(4) = 16 bytes
let stn_header_start = item.len();
item.extend_from_slice(&[0u8; 2]); // STN length placeholder
item.extend_from_slice(&[0u8; 2]); // reserved
item.push(stn_counts.0); // n_video
item.push(stn_counts.1); // n_audio
item.push(stn_counts.2); // n_pg
item.push(stn_counts.3); // n_ig
item.push(stn_counts.4); // n_sec_audio
item.push(stn_counts.5); // n_sec_video
item.push(stn_counts.6); // n_pip_pg
item.push(stn_counts.7); // n_dv
item.extend_from_slice(&[0u8; 4]); // reserved
// Stream entries
for se in stream_entries {
item.extend_from_slice(se);
}
// Patch STN length
let stn_len = (item.len() - stn_header_start - 2) as u16;
let stn_len_bytes = stn_len.to_be_bytes();
item[stn_header_start] = stn_len_bytes[0];
item[stn_header_start + 1] = stn_len_bytes[1];
}
// Write item_length(2) + item
let item_length = item.len() as u16;
buf.extend_from_slice(&item_length.to_be_bytes());
buf.extend_from_slice(&item);
}
// Patch PlayList length
let pl_len = (buf.len() - pl_start - 4) as u32;
let pl_len_bytes = pl_len.to_be_bytes();
buf[pl_start] = pl_len_bytes[0];
buf[pl_start + 1] = pl_len_bytes[1];
buf[pl_start + 2] = pl_len_bytes[2];
buf[pl_start + 3] = pl_len_bytes[3];
buf
}
/// Build a stream entry (stream_entry part + stream_attributes part).
/// stream_entry: type=0x01 (PlayItem stream), PID given.
/// For video: attrs = coding_type(1) + format_rate(1) [+ hdr_byte if HEVC]
/// For audio: attrs = coding_type(1) + format_rate(1) + language(3)
/// For PG: attrs = coding_type(1) + language(3)
fn build_stream_entry_video(pid: u16, coding_type: u8, format: u8, rate: u8, hdr: Option<u8>) -> Vec<u8> {
let mut out = Vec::new();
// Stream entry: length(1) + sub_path_type(1) + pid(2)
out.push(3); // se_len = 3 bytes (type + pid_hi + pid_lo)
out.push(0x01); // type: PlayItem stream
out.extend_from_slice(&pid.to_be_bytes());
// Stream attributes
let mut attrs = vec![coding_type, (format << 4) | rate];
if let Some(h) = hdr {
attrs.push(h);
}
out.push(attrs.len() as u8); // sa_len
out.extend_from_slice(&attrs);
out
}
fn build_stream_entry_audio(pid: u16, coding_type: u8, ch_layout: u8, sample_rate: u8, lang: &[u8; 3]) -> Vec<u8> {
let mut out = Vec::new();
out.push(3);
out.push(0x01);
out.extend_from_slice(&pid.to_be_bytes());
// attrs: coding_type(1) + format_rate(1) + language(3)
let attrs = vec![coding_type, (ch_layout << 4) | sample_rate, lang[0], lang[1], lang[2]];
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
fn build_stream_entry_pg(pid: u16, coding_type: u8, lang: &[u8; 3]) -> Vec<u8> {
let mut out = Vec::new();
out.push(3);
out.push(0x01);
out.extend_from_slice(&pid.to_be_bytes());
// attrs: coding_type(1) + language(3)
let attrs = vec![coding_type, lang[0], lang[1], lang[2]];
out.push(attrs.len() as u8);
out.extend_from_slice(&attrs);
out
}
#[test]
fn parse_valid_mpls() {
let in_time: u32 = 90000; // 2 seconds at 45kHz
let out_time: u32 = 4500000; // 100 seconds
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None); // H264, 1080p, 23.976
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng"); // TrueHD, 5.1, 48kHz
let pg = build_stream_entry_pg(0x1200, 0x90, b"eng"); // PGS subtitle
let data = build_mpls(
&[(b"00001", 1, in_time, out_time)],
(1, 1, 1, 0, 0, 0, 0, 0),
&[video, audio, pg],
);
let playlist = parse(&data).expect("should parse valid MPLS");
assert_eq!(playlist.version, "0200");
assert_eq!(playlist.play_items.len(), 1);
assert_eq!(playlist.play_items[0].clip_id, "00001");
assert_eq!(playlist.play_items[0].in_time, in_time);
assert_eq!(playlist.play_items[0].out_time, out_time);
assert_eq!(playlist.play_items[0].connection_condition, 1);
}
#[test]
fn parse_streams() {
let video = build_stream_entry_video(0x1011, 0x24, 8, 1, Some(0x12)); // HEVC, 2160p, 23.976, HDR10+BT.2020
let audio = build_stream_entry_audio(0x1100, 0x83, 6, 1, b"eng");
let pg = build_stream_entry_pg(0x1200, 0x90, b"fra");
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 1, 1, 0, 0, 0, 0, 0),
&[video, audio, pg],
);
let playlist = parse(&data).expect("should parse");
assert_eq!(playlist.streams.len(), 3);
// Video stream
let v = &playlist.streams[0];
assert_eq!(v.stream_type, 1);
assert_eq!(v.pid, 0x1011);
assert_eq!(v.coding_type, 0x24); // HEVC
assert_eq!(v.video_format, 8); // 2160p
assert_eq!(v.video_rate, 1); // 23.976
assert_eq!(v.dynamic_range, 1); // HDR10
assert_eq!(v.color_space, 2); // BT.2020
assert!(!v.secondary);
// Audio stream
let a = &playlist.streams[1];
assert_eq!(a.stream_type, 2);
assert_eq!(a.pid, 0x1100);
assert_eq!(a.coding_type, 0x83); // TrueHD
assert_eq!(a.audio_format, 6); // 5.1
assert_eq!(a.audio_rate, 1); // 48kHz
assert_eq!(a.language, "eng");
assert!(!a.secondary);
// PG subtitle stream
let s = &playlist.streams[2];
assert_eq!(s.stream_type, 3);
assert_eq!(s.pid, 0x1200);
assert_eq!(s.coding_type, 0x90); // PGS
assert_eq!(s.language, "fra");
assert!(!s.secondary);
}
#[test]
fn parse_invalid_magic() {
let mut data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(0, 0, 0, 0, 0, 0, 0, 0),
&[],
);
data[0] = b'X';
data[1] = b'X';
data[2] = b'X';
data[3] = b'X';
assert!(parse(&data).is_err());
}
#[test]
fn parse_truncated() {
// Less than 40 bytes
assert!(parse(&[0u8; 10]).is_err());
assert!(parse(b"MPLS0200").is_err());
assert!(parse(&[0u8; 39]).is_err());
}
#[test]
fn parse_multiple_play_items() {
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
let data = build_mpls(
&[
(b"00001", 1, 90000, 4500000),
(b"00002", 5, 4500000, 9000000),
(b"00003", 6, 9000000, 13500000),
],
(1, 0, 0, 0, 0, 0, 0, 0),
&[video],
);
let playlist = parse(&data).expect("should parse multiple play items");
assert_eq!(playlist.play_items.len(), 3);
assert_eq!(playlist.play_items[0].clip_id, "00001");
assert_eq!(playlist.play_items[0].connection_condition, 1);
assert_eq!(playlist.play_items[1].clip_id, "00002");
assert_eq!(playlist.play_items[1].connection_condition, 5);
assert_eq!(playlist.play_items[1].in_time, 4500000);
assert_eq!(playlist.play_items[2].clip_id, "00003");
assert_eq!(playlist.play_items[2].connection_condition, 6);
assert_eq!(playlist.play_items[2].out_time, 13500000);
}
#[test]
fn parse_secondary_streams() {
// Primary video
let video = build_stream_entry_video(0x1011, 0x1B, 6, 1, None);
// Secondary audio (stream_type 5): build as audio, parser overrides type to 5
let sec_audio_se = build_stream_entry_audio(0x1A00, 0x83, 3, 1, b"eng");
// Need ref bytes after secondary audio: num_refs(1) + reserved(1) = 2 bytes min
let mut sec_audio_with_refs = sec_audio_se;
sec_audio_with_refs.push(0); // num_refs = 0
sec_audio_with_refs.push(0); // reserved
// Secondary video (stream_type 6): build as video, parser overrides type to 6
let sec_video_se = build_stream_entry_video(0x1B00, 0x1B, 4, 1, None);
// Need ref bytes: n_arefs(1) + reserved(1) + n_prefs(1) + reserved(1) = 4 bytes
let mut sec_video_with_refs = sec_video_se;
sec_video_with_refs.push(0); // n_arefs = 0
sec_video_with_refs.push(0); // reserved
sec_video_with_refs.push(0); // n_prefs = 0
sec_video_with_refs.push(0); // reserved
let data = build_mpls(
&[(b"00001", 1, 0, 9000000)],
(1, 0, 0, 0, 1, 1, 0, 0), // 1 video, 0 audio, 0 pg, 0 ig, 1 sec_audio, 1 sec_video
&[video, sec_audio_with_refs, sec_video_with_refs],
);
let playlist = parse(&data).expect("should parse secondary streams");
// Should have 3 streams: primary video, secondary audio, secondary video
assert_eq!(playlist.streams.len(), 3);
// Primary video
assert_eq!(playlist.streams[0].stream_type, 1);
assert!(!playlist.streams[0].secondary);
// Secondary audio
assert_eq!(playlist.streams[1].stream_type, 5);
assert!(playlist.streams[1].secondary);
assert_eq!(playlist.streams[1].pid, 0x1A00);
// Secondary video
assert_eq!(playlist.streams[2].stream_type, 6);
assert!(playlist.streams[2].secondary);
assert_eq!(playlist.streams[2].pid, 0x1B00);
}
}
+134
View File
@@ -47,3 +47,137 @@ fn find_ac3_sync(data: &[u8]) -> Option<usize> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1100, pts, dts: None, data }
}
// --- syncword detection ---
#[test]
fn find_ac3_sync_at_start() {
let data = [0x0B, 0x77, 0x01, 0x02, 0x03];
assert_eq!(find_ac3_sync(&data), Some(0));
}
#[test]
fn find_ac3_sync_with_garbage_prefix() {
let data = [0xFF, 0xFE, 0x0B, 0x77, 0x01, 0x02];
assert_eq!(find_ac3_sync(&data), Some(2));
}
#[test]
fn find_ac3_sync_none() {
let data = [0x0B, 0x78, 0x00, 0x00];
assert_eq!(find_ac3_sync(&data), None);
}
#[test]
fn find_ac3_sync_empty() {
let data: [u8; 0] = [];
assert_eq!(find_ac3_sync(&data), None);
}
// --- parse syncword → frame extracted ---
#[test]
fn parse_syncword() {
let mut parser = Ac3Parser::new();
// AC3 frame starting with syncword
let data = vec![0x0B, 0x77, 0x44, 0x55, 0x66, 0x77, 0x88];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn parse_syncword_with_garbage_prefix() {
let mut parser = Ac3Parser::new();
// Garbage bytes before syncword
let data = vec![0xFF, 0xFE, 0x0B, 0x77, 0x44, 0x55];
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Data should start from the syncword
assert_eq!(frames[0].data[0], 0x0B);
assert_eq!(frames[0].data[1], 0x77);
assert_eq!(frames[0].data.len(), 4); // syncword + 2 payload bytes
}
// --- all frames are keyframes ---
#[test]
fn all_keyframes() {
let mut parser = Ac3Parser::new();
for i in 0..5 {
let data = vec![0x0B, 0x77, 0x00, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "AC3 frame {} should be a keyframe", i);
}
}
// --- codec_private is None ---
#[test]
fn codec_private_none() {
let parser = Ac3Parser::new();
assert!(parser.codec_private().is_none());
}
// --- empty / too-short PES ---
#[test]
fn parse_empty_pes() {
let mut parser = Ac3Parser::new();
let pes = make_pes(Vec::new(), Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
#[test]
fn parse_single_byte_pes() {
let mut parser = Ac3Parser::new();
let pes = make_pes(vec![0x0B], Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
// --- PTS conversion ---
#[test]
fn pts_conversion() {
let mut parser = Ac3Parser::new();
let data = vec![0x0B, 0x77, 0x00, 0x01];
// 45000 ticks = 0.5 seconds → 500_000_000 ns
let pes = make_pes(data, Some(45000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 500_000_000);
}
// --- None PTS ---
#[test]
fn no_pts() {
let mut parser = Ac3Parser::new();
let data = vec![0x0B, 0x77, 0x00, 0x01];
let pes = make_pes(data, None);
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 0);
}
}
+57
View File
@@ -24,3 +24,60 @@ impl CodecParser for DtsParser {
fn codec_private(&self) -> Option<Vec<u8>> { None }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1100, pts, dts: None, data }
}
#[test]
fn parse_basic_frame() {
let mut parser = DtsParser::new();
// DTS core syncword: 7F FE 80 01 + payload
let data = vec![0x7F, 0xFE, 0x80, 0x01, 0xAA, 0xBB, 0xCC];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn all_keyframes() {
let mut parser = DtsParser::new();
for i in 0..3 {
let data = vec![0x7F, 0xFE, 0x80, 0x01, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "DTS frame should always be keyframe");
}
}
#[test]
fn codec_private_none() {
let parser = DtsParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn parse_empty_pes() {
let mut parser = DtsParser::new();
let pes = make_pes(Vec::new(), Some(0));
assert!(parser.parse(&pes).is_empty());
}
#[test]
fn no_pts() {
let mut parser = DtsParser::new();
let pes = make_pes(vec![0x7F, 0xFE, 0x80, 0x01], None);
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 0);
}
}
+250
View File
@@ -189,3 +189,253 @@ pub fn skip_start_code(data: &[u8], pos: usize) -> Option<usize> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1011, pts, dts: None, data }
}
// --- find_start_code tests ---
#[test]
fn find_start_code_3byte() {
let data = [0x00, 0x00, 0x01, 0x65];
assert_eq!(find_start_code(&data, 0), Some(0));
}
#[test]
fn find_start_code_4byte() {
let data = [0x00, 0x00, 0x00, 0x01, 0x65];
// find_start_code looks for 00 00 01 pattern, which starts at offset 1 in a 4-byte start code
assert_eq!(find_start_code(&data, 0), Some(1));
}
#[test]
fn find_start_code_offset() {
let data = [0xFF, 0xFF, 0x00, 0x00, 0x01, 0x09];
assert_eq!(find_start_code(&data, 0), Some(2));
}
#[test]
fn find_start_code_none() {
let data = [0x00, 0x00, 0x00, 0x00];
assert_eq!(find_start_code(&data, 0), None);
}
#[test]
fn find_start_code_too_short() {
let data = [0x00, 0x00];
assert_eq!(find_start_code(&data, 0), None);
}
// --- parse SPS+PPS → codec_private ---
#[test]
fn parse_sps_pps() {
let mut parser = H264Parser::new();
// Build PES with SPS (type 7) + PPS (type 8) + IDR slice (type 5)
// SPS NAL: 0x67 = 0_11_00111 (nal_type = 7), followed by profile/compat/level + payload
// PPS NAL: 0x68 = 0_11_01000 (nal_type = 8)
let mut data = Vec::new();
// SPS: 00 00 01 [67 42 00 1E <payload>]
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x67); // SPS
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB, 0xCD]); // profile=0x42, compat=0x00, level=0x1E
// PPS: 00 00 01 [68 <payload>]
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x68); // PPS
data.extend_from_slice(&[0xCE, 0x01]);
// IDR slice: 00 00 01 [65 <payload>]
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x65); // IDR
data.extend_from_slice(&[0x88, 0x00, 0x10]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
// codec_private should now be available
let cp = parser.codec_private();
assert!(cp.is_some(), "codec_private should be Some after seeing SPS+PPS");
let cp = cp.unwrap();
// AVCDecoderConfigurationRecord checks
assert_eq!(cp[0], 1, "configurationVersion");
assert_eq!(cp[1], 0x42, "profile from SPS[1]");
assert_eq!(cp[2], 0x00, "compatibility from SPS[2]");
assert_eq!(cp[3], 0x1E, "level from SPS[3]");
assert_eq!(cp[4], 0xFF, "reserved + lengthSizeMinusOne=3");
assert_eq!(cp[5], 0xE1, "reserved + numSPS=1");
// Frames should have been produced
assert_eq!(frames.len(), 1);
}
#[test]
fn codec_private_none_before_sps_pps() {
let parser = H264Parser::new();
assert!(parser.codec_private().is_none());
}
// --- IDR keyframe detection ---
#[test]
fn parse_idr_keyframe() {
let mut parser = H264Parser::new();
// PES with IDR NAL (type 5 = 0x65)
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x65); // IDR slice (nal_type = 5)
data.extend_from_slice(&[0x88, 0x00, 0x10, 0x20]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "IDR slice should be detected as keyframe");
}
// --- non-IDR → not keyframe ---
#[test]
fn parse_non_idr() {
let mut parser = H264Parser::new();
// PES with non-IDR slice (type 1 = 0x61 or 0x41)
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x41); // non-IDR coded slice (nal_type = 1)
data.extend_from_slice(&[0x9A, 0x00, 0x10]);
let pes = make_pes(data, Some(180000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "non-IDR slice should not be keyframe");
}
// --- length prefix conversion ---
#[test]
fn length_prefix_conversion() {
let mut parser = H264Parser::new();
// PES with a single non-IDR NAL
let nal_payload = [0x41, 0xAA, 0xBB, 0xCC, 0xDD]; // type 1, 5 bytes
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&nal_payload);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
let frame_data = &frames[0].data;
// Should start with 4-byte big-endian length prefix
assert!(frame_data.len() >= 4, "frame data should have length prefix");
let length = u32::from_be_bytes([frame_data[0], frame_data[1], frame_data[2], frame_data[3]]);
assert_eq!(length as usize, nal_payload.len(), "length prefix should match NAL size");
// Followed by the NAL data itself
assert_eq!(&frame_data[4..], &nal_payload);
// No start code (00 00 01) should appear in the output
for i in 0..frame_data.len().saturating_sub(2) {
let is_sc = frame_data[i] == 0x00 && frame_data[i + 1] == 0x00 && frame_data[i + 2] == 0x01;
assert!(!is_sc, "output should not contain Annex B start codes");
}
}
// --- SPS/PPS/AUD are stripped from frame data ---
#[test]
fn sps_pps_aud_stripped_from_frame_data() {
let mut parser = H264Parser::new();
let mut data = Vec::new();
// AUD (type 9)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x09);
data.push(0xF0);
// SPS (type 7)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x67);
data.extend_from_slice(&[0x42, 0x00, 0x1E, 0xAB]);
// PPS (type 8)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x68);
data.extend_from_slice(&[0xCE, 0x01]);
// IDR (type 5) - only this should appear in frame data
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x65);
data.extend_from_slice(&[0x88, 0x00]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Frame data should only contain the IDR NAL (length-prefixed)
let fd = &frames[0].data;
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
// IDR NAL is 0x65, 0x88 (trailing 0x00 is stripped as potential start code prefix)
assert_eq!(length, 2);
assert_eq!(fd[4], 0x65); // IDR NAL type byte
}
// --- PTS conversion ---
#[test]
fn pts_conversion() {
let mut parser = H264Parser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x41);
data.extend_from_slice(&[0x00, 0x10]);
// PTS = 90000 (1 second at 90kHz) → 1_000_000_000 ns
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
// --- empty PES ---
#[test]
fn parse_empty_pes() {
let mut parser = H264Parser::new();
let pes = make_pes(Vec::new(), Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
// --- DTS preferred over PTS when present ---
#[test]
fn dts_preferred_over_pts() {
let mut parser = H264Parser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.push(0x41);
data.extend_from_slice(&[0x00, 0x10]);
let pes = PesPacket {
pid: 0x1011,
pts: Some(180000), // 2 seconds
dts: Some(90000), // 1 second
data,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// DTS should be used, not PTS
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
}
+256
View File
@@ -169,3 +169,259 @@ impl CodecParser for HevcParser {
Some(record)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1011, pts, dts: None, data }
}
/// Build an HEVC NAL header (2 bytes). Type is bits 1-6 of first byte.
/// Format: forbidden(1) | type(6) | layer_id_high(1) || layer_id_low(5) | tid(3)
fn hevc_nal_header(nal_type: u8) -> [u8; 2] {
[(nal_type & 0x3F) << 1, 0x01] // tid=1
}
// --- VPS+SPS+PPS → codec_private ---
#[test]
fn parse_vps_sps_pps() {
let mut parser = HevcParser::new();
let mut data = Vec::new();
// VPS (type 32)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let vps_hdr = hevc_nal_header(32);
data.extend_from_slice(&vps_hdr);
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]); // VPS payload
// SPS (type 33)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let sps_hdr = hevc_nal_header(33);
data.extend_from_slice(&sps_hdr);
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
0x09, 0x0A, 0x0B, 0x0C, 0x0D]); // SPS payload (>12 bytes for level)
// PPS (type 34)
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let pps_hdr = hevc_nal_header(34);
data.extend_from_slice(&pps_hdr);
data.extend_from_slice(&[0xDD, 0xEE]); // PPS payload
// IRAP slice (type 19 = IDR_W_RADL) so a frame is emitted
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let idr_hdr = hevc_nal_header(19);
data.extend_from_slice(&idr_hdr);
data.extend_from_slice(&[0x10, 0x20, 0x30]);
let pes = make_pes(data, Some(90000));
let _frames = parser.parse(&pes);
let cp = parser.codec_private();
assert!(cp.is_some(), "codec_private should be Some after VPS+SPS+PPS");
let cp = cp.unwrap();
// configurationVersion = 1
assert_eq!(cp[0], 1);
// numOfArrays = 3 (VPS, SPS, PPS)
assert_eq!(cp[22], 3);
// Should be longer than the minimal header (23 bytes) + array entries
assert!(cp.len() > 23, "codec_private should contain VPS+SPS+PPS data");
}
#[test]
fn codec_private_none_before_params() {
let parser = HevcParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn codec_private_none_missing_pps() {
let mut parser = HevcParser::new();
// Only VPS + SPS, no PPS
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA, 0xBB]);
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(33));
data.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]);
// Add a slice so parse doesn't return empty
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(1)); // TRAIL_R
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
parser.parse(&pes);
assert!(parser.codec_private().is_none(), "should be None without PPS");
}
// --- IRAP keyframe detection ---
#[test]
fn parse_irap_keyframe_idr_w_radl() {
let mut parser = HevcParser::new();
let mut data = Vec::new();
// IDR_W_RADL = type 19
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(19));
data.extend_from_slice(&[0x10, 0x20, 0x30]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "IDR_W_RADL (type 19) should be keyframe");
}
#[test]
fn parse_irap_keyframe_bla() {
let mut parser = HevcParser::new();
// BLA_W_LP = type 16
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(16));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "BLA_W_LP (type 16) should be keyframe");
}
#[test]
fn parse_irap_keyframe_cra() {
let mut parser = HevcParser::new();
// CRA_NUT = type 21
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(21));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "CRA (type 21) should be keyframe");
}
#[test]
fn parse_irap_type_23() {
let mut parser = HevcParser::new();
// RSV_IRAP_VCL23 = type 23 (upper boundary)
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(23));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "type 23 should be keyframe");
}
// --- non-IRAP (trailing) → not keyframe ---
#[test]
fn parse_trailing_not_keyframe() {
let mut parser = HevcParser::new();
// TRAIL_R = type 1
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(1));
data.extend_from_slice(&[0x10, 0x20, 0x30]);
let pes = make_pes(data, Some(180000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "TRAIL_R (type 1) should not be keyframe");
}
#[test]
fn parse_tsa_not_keyframe() {
let mut parser = HevcParser::new();
// TSA_N = type 2
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(2));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "TSA_N (type 2) should not be keyframe");
}
// --- VPS/SPS/PPS stripped from frame data ---
#[test]
fn param_sets_stripped_from_frame() {
let mut parser = HevcParser::new();
let mut data = Vec::new();
// VPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(32));
data.extend_from_slice(&[0xAA]);
// SPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(33));
data.extend_from_slice(&[0xBB]);
// PPS
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(34));
data.extend_from_slice(&[0xCC]);
// IDR slice
data.extend_from_slice(&[0x00, 0x00, 0x01]);
let idr_hdr = hevc_nal_header(19);
data.extend_from_slice(&idr_hdr);
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Frame data should only have the IDR NAL (length-prefixed)
let fd = &frames[0].data;
let length = u32::from_be_bytes([fd[0], fd[1], fd[2], fd[3]]);
// IDR NAL = 2 bytes header + 2 bytes payload = 4 bytes
assert_eq!(length as usize + 4, fd.len(), "frame should contain exactly one length-prefixed NAL");
}
// --- empty PES ---
#[test]
fn parse_empty_pes() {
let mut parser = HevcParser::new();
let pes = make_pes(Vec::new(), Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
// --- PTS conversion ---
#[test]
fn pts_conversion() {
let mut parser = HevcParser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01]);
data.extend_from_slice(&hevc_nal_header(1));
data.extend_from_slice(&[0x10, 0x20]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
}
+48
View File
@@ -23,3 +23,51 @@ impl CodecParser for PgsParser {
fn codec_private(&self) -> Option<Vec<u8>> { None }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1200, pts, dts: None, data }
}
#[test]
fn parse_basic_segment() {
let mut parser = PgsParser::new();
// PGS segment data (PCS = presentation composition segment)
let data = vec![0x16, 0x00, 0x00, 0x11, 0x01, 0x02, 0x03];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn all_keyframes() {
let mut parser = PgsParser::new();
for i in 0..3 {
let data = vec![0x16, 0x00, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "PGS segment should always be keyframe");
}
}
#[test]
fn codec_private_none() {
let parser = PgsParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn parse_empty_pes() {
let mut parser = PgsParser::new();
let pes = make_pes(Vec::new(), Some(0));
assert!(parser.parse(&pes).is_empty());
}
}
+48
View File
@@ -25,3 +25,51 @@ impl CodecParser for TrueHdParser {
fn codec_private(&self) -> Option<Vec<u8>> { None }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1100, pts, dts: None, data }
}
#[test]
fn parse_basic_frame() {
let mut parser = TrueHdParser::new();
// TrueHD major sync: F8 72 6F BA (at 4-byte aligned position) + payload
let data = vec![0xF8, 0x72, 0x6F, 0xBA, 0x01, 0x02, 0x03, 0x04];
let pes = make_pes(data.clone(), Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].data, data);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
#[test]
fn all_keyframes() {
let mut parser = TrueHdParser::new();
for i in 0..3 {
let data = vec![0xF8, 0x72, 0x6F, 0xBA, i];
let pes = make_pes(data, Some(90000 * i as i64));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(frames[0].keyframe, "TrueHD frame should always be keyframe");
}
}
#[test]
fn codec_private_none() {
let parser = TrueHdParser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn parse_empty_pes() {
let mut parser = TrueHdParser::new();
let pes = make_pes(Vec::new(), Some(0));
assert!(parser.parse(&pes).is_empty());
}
}
+222
View File
@@ -120,3 +120,225 @@ fn find_next_sc(data: &[u8], from: usize) -> Option<usize> {
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mux::ts::PesPacket;
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { pid: 0x1011, pts, dts: None, data }
}
/// Build a VC-1 PES with sequence header + entry point + frame start code.
fn build_vc1_iframe_pes() -> Vec<u8> {
let mut data = Vec::new();
// Sequence header: 00 00 01 0F + payload
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
data.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
// Entry point: 00 00 01 0E + payload
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_ENTRY_POINT]);
data.extend_from_slice(&[0x11, 0x22, 0x33, 0x44]);
// Frame: 00 00 01 0D + payload
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66, 0x77, 0x88, 0x99]);
data
}
// --- sequence header detection ---
#[test]
fn parse_sequence_header() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Sequence header present → keyframe
assert!(frames[0].keyframe, "PES with sequence header should be keyframe");
// seq_header should be stored internally
assert!(parser.seq_header.is_some());
}
#[test]
fn parse_entry_point() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data, Some(0));
parser.parse(&pes);
assert!(parser.entry_point.is_some());
}
// --- codec_private is BITMAPINFOHEADER (40+ bytes) ---
#[test]
fn codec_private_bitmapinfoheader() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data, Some(0));
parser.parse(&pes);
let cp = parser.codec_private();
assert!(cp.is_some(), "codec_private should be Some after seq header + entry point");
let cp = cp.unwrap();
// BITMAPINFOHEADER is 40 bytes + extra data
assert!(cp.len() >= 40, "codec_private should be at least 40 bytes (BITMAPINFOHEADER)");
// biSize (first 4 bytes, little-endian) should equal total length
let bi_size = u32::from_le_bytes([cp[0], cp[1], cp[2], cp[3]]);
assert_eq!(bi_size as usize, cp.len(), "biSize should match total codec_private length");
// biCompression = "WVC1" at offset 16
assert_eq!(&cp[16..20], b"WVC1", "FOURCC should be WVC1");
// biWidth at offset 4 (little-endian u32) = 1920
let width = u32::from_le_bytes([cp[4], cp[5], cp[6], cp[7]]);
assert_eq!(width, 1920);
// biHeight at offset 8 (little-endian u32) = 1080
let height = u32::from_le_bytes([cp[8], cp[9], cp[10], cp[11]]);
assert_eq!(height, 1080);
}
#[test]
fn codec_private_none_before_data() {
let parser = Vc1Parser::new();
assert!(parser.codec_private().is_none());
}
#[test]
fn codec_private_none_missing_entry_point() {
let mut parser = Vc1Parser::new();
// Only sequence header, no entry point
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
data.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66]);
let pes = make_pes(data, Some(0));
parser.parse(&pes);
assert!(parser.codec_private().is_none(), "should be None without entry point");
}
// --- frame without sequence header → not keyframe ---
#[test]
fn parse_non_keyframe() {
let mut parser = Vc1Parser::new();
// PES with only a frame start code (no sequence header)
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66, 0x77]);
let pes = make_pes(data, Some(180000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert!(!frames[0].keyframe, "frame without sequence header should not be keyframe");
}
// --- frame data starts from frame start code ---
#[test]
fn frame_data_starts_at_frame_sc() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data.clone(), Some(0));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
// Frame data should start with the frame start code (00 00 01 0D)
assert!(frames[0].data.len() >= 4);
assert_eq!(&frames[0].data[0..4], &[0x00, 0x00, 0x01, SC_FRAME]);
}
// --- empty PES ---
#[test]
fn parse_empty_pes() {
let mut parser = Vc1Parser::new();
let pes = make_pes(Vec::new(), Some(0));
let frames = parser.parse(&pes);
assert!(frames.is_empty());
}
// --- PTS conversion ---
#[test]
fn pts_conversion() {
let mut parser = Vc1Parser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66]);
let pes = make_pes(data, Some(90000));
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
// --- DTS preferred over PTS ---
#[test]
fn dts_preferred_over_pts() {
let mut parser = Vc1Parser::new();
let mut data = Vec::new();
data.extend_from_slice(&[0x00, 0x00, 0x01, SC_FRAME]);
data.extend_from_slice(&[0x55, 0x66]);
let pes = PesPacket {
pid: 0x1011,
pts: Some(180000),
dts: Some(90000),
data,
};
let frames = parser.parse(&pes);
assert_eq!(frames.len(), 1);
assert_eq!(frames[0].pts_ns, 1_000_000_000);
}
// --- find_next_sc utility ---
#[test]
fn find_next_sc_basic() {
let data = [0xAA, 0x00, 0x00, 0x01, 0x0D, 0xBB];
assert_eq!(find_next_sc(&data, 0), Some(1));
}
#[test]
fn find_next_sc_none() {
let data = [0xAA, 0xBB, 0xCC];
assert_eq!(find_next_sc(&data, 0), None);
}
// --- codec_private extra data contains seq header + entry point ---
#[test]
fn codec_private_contains_extra_data() {
let mut parser = Vc1Parser::new();
let data = build_vc1_iframe_pes();
let pes = make_pes(data, Some(0));
parser.parse(&pes);
let cp = parser.codec_private().unwrap();
// After the 40-byte BITMAPINFOHEADER, we should have seq_header + entry_point data
let extra = &cp[40..];
assert!(!extra.is_empty(), "extra data after BITMAPINFOHEADER should not be empty");
// Extra data should start with the sequence header start code
assert_eq!(&extra[0..4], &[0x00, 0x00, 0x01, SC_SEQUENCE_HEADER]);
}
}
+121
View File
@@ -413,4 +413,125 @@ mod tests {
// EBML header: 1A 45 DF A3, then 8-byte size, then content
assert_eq!(&data[0..4], &[0x1A, 0x45, 0xDF, 0xA3]);
}
#[test]
fn write_read_id_roundtrip() {
// 1-byte IDs have high bit set (0x80..=0xFF)
for &id in &[0x80u32, 0xA3, 0xFF] {
let mut buf = Vec::new();
write_id(&mut buf, id).unwrap();
assert_eq!(buf.len(), 1);
let mut cursor = Cursor::new(&buf);
let (read_back, consumed) = read_id(&mut cursor).unwrap();
assert_eq!(read_back, id, "1-byte ID roundtrip failed for 0x{:X}", id);
assert_eq!(consumed, 1);
}
// 2-byte IDs (0x4000..=0x7FFF)
for &id in &[0x4286u32, 0x4282, 0x7FFF] {
let mut buf = Vec::new();
write_id(&mut buf, id).unwrap();
assert_eq!(buf.len(), 2);
let mut cursor = Cursor::new(&buf);
let (read_back, consumed) = read_id(&mut cursor).unwrap();
assert_eq!(read_back, id, "2-byte ID roundtrip failed for 0x{:X}", id);
assert_eq!(consumed, 2);
}
// 3-byte IDs (0x200000..=0x3FFFFF)
for &id in &[0x22B59Cu32, 0x23E383] {
let mut buf = Vec::new();
write_id(&mut buf, id).unwrap();
assert_eq!(buf.len(), 3);
let mut cursor = Cursor::new(&buf);
let (read_back, consumed) = read_id(&mut cursor).unwrap();
assert_eq!(read_back, id, "3-byte ID roundtrip failed for 0x{:X}", id);
assert_eq!(consumed, 3);
}
// 4-byte IDs (0x10000000..=0x1FFFFFFF)
for &id in &[EBML, SEGMENT, TRACKS, CLUSTER] {
let mut buf = Vec::new();
write_id(&mut buf, id).unwrap();
assert_eq!(buf.len(), 4);
let mut cursor = Cursor::new(&buf);
let (read_back, consumed) = read_id(&mut cursor).unwrap();
assert_eq!(read_back, id, "4-byte ID roundtrip failed for 0x{:X}", id);
assert_eq!(consumed, 4);
}
}
#[test]
fn write_read_size_roundtrip() {
let test_sizes: &[u64] = &[0, 1, 0x7E, 127, 128, 0x3FFE, 16383, 16384, 0x1FFFFE, 0x0FFFFFFE, 0x1_0000_0000];
for &size in test_sizes {
let mut buf = Vec::new();
write_size(&mut buf, size).unwrap();
let mut cursor = Cursor::new(&buf);
let (read_back, _consumed) = read_size(&mut cursor).unwrap();
assert_eq!(read_back, size, "size roundtrip failed for {}", size);
}
}
#[test]
fn write_read_uint_roundtrip() {
let test_vals: &[u64] = &[0, 1, 127, 255, 256, 0xFFFF, 0xFF_FFFF, 0xFFFF_FFFF, 1_000_000_000_000];
let test_id = EBML_VERSION;
for &val in test_vals {
let mut buf = Vec::new();
write_uint(&mut buf, test_id, val).unwrap();
let mut cursor = Cursor::new(&buf);
let (id, id_len) = read_id(&mut cursor).unwrap();
assert_eq!(id, test_id);
let (size, _) = read_size(&mut cursor).unwrap();
let read_val = read_uint_val(&mut cursor, size as usize).unwrap();
assert_eq!(read_val, val, "uint roundtrip failed for {}", val);
}
}
#[test]
fn write_read_string_roundtrip() {
let test_strings = &["", "matroska", "freemkv", "Hello, World!", "unicode: \u{1F600}"];
let test_id = EBML_DOC_TYPE;
for &s in test_strings {
let mut buf = Vec::new();
write_string(&mut buf, test_id, s).unwrap();
let mut cursor = Cursor::new(&buf);
let (id, _) = read_id(&mut cursor).unwrap();
assert_eq!(id, test_id);
let (size, _) = read_size(&mut cursor).unwrap();
let read_s = read_string_val(&mut cursor, size as usize).unwrap();
assert_eq!(read_s, s, "string roundtrip failed for {:?}", s);
}
}
#[test]
fn write_read_float_roundtrip() {
let test_vals: &[f64] = &[0.0, 1.0, -1.0, 3.14159265358979, 48000.0, 7200000.0, f64::MIN, f64::MAX];
let test_id = DURATION;
for &val in test_vals {
let mut buf = Vec::new();
write_float(&mut buf, test_id, val).unwrap();
let mut cursor = Cursor::new(&buf);
let (id, _) = read_id(&mut cursor).unwrap();
assert_eq!(id, test_id);
let (size, _) = read_size(&mut cursor).unwrap();
assert_eq!(size, 8);
let read_val = read_float_val(&mut cursor, size as usize).unwrap();
assert_eq!(read_val.to_bits(), val.to_bits(), "float roundtrip failed for {}", val);
}
}
#[test]
fn unknown_size() {
let mut buf = Vec::new();
write_unknown_size(&mut buf).unwrap();
assert_eq!(buf.len(), 8);
assert_eq!(buf[0], 0x01);
for &b in &buf[1..] {
assert_eq!(b, 0xFF, "unknown size bytes should all be 0xFF after first byte");
}
// Reading it back should yield u64::MAX
let mut cursor = Cursor::new(&buf);
let (size, consumed) = read_size(&mut cursor).unwrap();
assert_eq!(size, u64::MAX);
assert_eq!(consumed, 8);
}
}
+75
View File
@@ -198,6 +198,81 @@ impl Read for IsoStream {
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use crate::sector::SectorReader;
#[test]
fn iso_reader_read_sectors() {
// Create a temp file with known sector data
let dir = std::env::temp_dir();
let path = dir.join("libfreemkv_test_iso_sectors.iso");
let path_str = path.to_str().unwrap();
// Write 4 sectors of known data
{
let mut f = File::create(&path).unwrap();
for sector_idx in 0u8..4 {
let mut sector = [sector_idx; SECTOR_SIZE as usize];
sector[0] = sector_idx;
sector[2047] = sector_idx.wrapping_mul(0x37);
f.write_all(&sector).unwrap();
}
f.flush().unwrap();
}
let mut reader = IsoSectorReader::open(path_str).unwrap();
assert_eq!(reader.capacity(), 4);
// Read sector 0
let mut buf = [0u8; SECTOR_SIZE as usize];
let n = reader.read_sectors(0, 1, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE as usize);
assert_eq!(buf[0], 0);
assert_eq!(buf[2047], 0u8.wrapping_mul(0x37));
// Read sector 2
let n = reader.read_sectors(2, 1, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE as usize);
assert_eq!(buf[0], 2);
assert_eq!(buf[1], 2); // filled with sector_idx
assert_eq!(buf[2047], 2u8.wrapping_mul(0x37));
// Read 2 sectors at once (sectors 1 and 2)
let mut buf2 = [0u8; SECTOR_SIZE as usize * 2];
let n = reader.read_sectors(1, 2, &mut buf2).unwrap();
assert_eq!(n, SECTOR_SIZE as usize * 2);
assert_eq!(buf2[0], 1); // sector 1 first byte
assert_eq!(buf2[SECTOR_SIZE as usize], 2); // sector 2 first byte
// Clean up
let _ = std::fs::remove_file(&path);
}
#[test]
fn iso_reader_capacity() {
let dir = std::env::temp_dir();
let path = dir.join("libfreemkv_test_iso_capacity.iso");
let path_str = path.to_str().unwrap();
// Write exactly 10 sectors
{
let mut f = File::create(&path).unwrap();
let data = vec![0u8; SECTOR_SIZE as usize * 10];
f.write_all(&data).unwrap();
f.flush().unwrap();
}
let reader = IsoSectorReader::open(path_str).unwrap();
assert_eq!(reader.capacity(), 10);
// Clean up
let _ = std::fs::remove_file(&path);
}
}
impl Write for IsoStream {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
match self.writer.as_mut() {
+45
View File
@@ -41,3 +41,48 @@ impl Read for NullStream {
Err(io::Error::new(io::ErrorKind::Unsupported, "null stream is write-only"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn null_counts_bytes() {
let mut ns = NullStream::new();
assert_eq!(ns.bytes_written(), 0);
ns.write_all(&[0u8; 100]).unwrap();
assert_eq!(ns.bytes_written(), 100);
ns.write_all(&[1u8; 50]).unwrap();
assert_eq!(ns.bytes_written(), 150);
// Single write returns correct count
let n = ns.write(&[0u8; 200]).unwrap();
assert_eq!(n, 200);
assert_eq!(ns.bytes_written(), 350);
}
#[test]
fn null_read_errors() {
let mut ns = NullStream::new();
let mut buf = [0u8; 10];
let err = ns.read(&mut buf).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
}
#[test]
fn null_finish_ok() {
let mut ns = NullStream::new();
ns.write_all(&[0u8; 1000]).unwrap();
ns.finish().unwrap();
}
#[test]
fn null_implements_iostream() {
let ns = NullStream::new();
let mut boxed: Box<dyn IOStream> = Box::new(ns);
boxed.write_all(&[0u8; 50]).unwrap();
let info = boxed.info();
assert_eq!(info.streams.len(), 0);
boxed.finish().unwrap();
}
}
+23
View File
@@ -75,3 +75,26 @@ impl Write for StdioStream {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
#[test]
fn stdio_output_write_errors_on_read() {
let mut stream = StdioStream::output();
let mut buf = [0u8; 10];
let err = stream.read(&mut buf).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
assert!(err.to_string().contains("cannot read"), "got: {}", err);
}
#[test]
fn stdio_input_read_errors_on_write() {
let mut stream = StdioStream::input();
let err = stream.write(&[0u8; 10]).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::Unsupported);
assert!(err.to_string().contains("cannot write"), "got: {}", err);
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Disc scanning pipeline tests.
use std::collections::HashMap;
use libfreemkv::error::Result;
use libfreemkv::sector::SectorReader;
use libfreemkv::{Disc, DiscTitle, ScanOptions};
const SECTOR_SIZE: usize = 2048;
/// Minimal mock sector reader for disc scan tests.
struct MockSectorReader {
sectors: HashMap<u32, Vec<u8>>,
}
impl MockSectorReader {
fn new() -> Self {
Self { sectors: HashMap::new() }
}
}
impl SectorReader for MockSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let total = count as usize * SECTOR_SIZE;
for i in 0..count as u32 {
let offset = i as usize * SECTOR_SIZE;
if let Some(data) = self.sectors.get(&(lba + i)) {
buf[offset..offset + SECTOR_SIZE].copy_from_slice(data);
} else {
buf[offset..offset + SECTOR_SIZE].fill(0);
}
}
Ok(total)
}
}
// ── scan_image tests ───────────────────────────────────────────────────────
#[test]
fn scan_image_empty_reader() {
// An empty reader has no AVDP at sector 256 -> UDF parse fails
let mut reader = MockSectorReader::new();
let opts = ScanOptions::default();
let result = Disc::scan_image(&mut reader, 0, &opts);
assert!(result.is_err(), "scan_image should fail with empty reader (no AVDP)");
}
// ── DiscTitle tests ────────────────────────────────────────────────────────
#[test]
fn disc_title_empty() {
let t = DiscTitle::empty();
assert_eq!(t.playlist, "");
assert_eq!(t.playlist_id, 0);
assert_eq!(t.duration_secs, 0.0);
assert_eq!(t.size_bytes, 0);
assert!(t.clips.is_empty());
assert!(t.streams.is_empty());
assert!(t.extents.is_empty());
}
#[test]
fn disc_title_duration_display() {
let mut t = DiscTitle::empty();
// 2 hours 15 minutes = 8100 seconds
t.duration_secs = 8100.0;
assert_eq!(t.duration_display(), "2h 15m");
// 0 hours 5 minutes = 300 seconds
t.duration_secs = 300.0;
assert_eq!(t.duration_display(), "0h 05m");
// Exact hour boundary
t.duration_secs = 3600.0;
assert_eq!(t.duration_display(), "1h 00m");
// Large value: 10 hours 30 minutes
t.duration_secs = 37800.0;
assert_eq!(t.duration_display(), "10h 30m");
}
#[test]
fn disc_title_size_gb() {
let mut t = DiscTitle::empty();
// Exactly 1 GiB
t.size_bytes = 1024 * 1024 * 1024;
assert!((t.size_gb() - 1.0).abs() < 0.001);
// 50 GiB (typical BD)
t.size_bytes = 50 * 1024 * 1024 * 1024;
assert!((t.size_gb() - 50.0).abs() < 0.001);
// Zero
t.size_bytes = 0;
assert_eq!(t.size_gb(), 0.0);
}
#[test]
fn disc_title_total_sectors() {
let mut t = DiscTitle::empty();
assert_eq!(t.total_sectors(), 0);
t.extents.push(libfreemkv::Extent { start_lba: 0, sector_count: 100 });
t.extents.push(libfreemkv::Extent { start_lba: 200, sector_count: 50 });
assert_eq!(t.total_sectors(), 150);
}
// ── ScanOptions tests ──────────────────────────────────────────────────────
#[test]
fn scan_options_default() {
let opts = ScanOptions::default();
assert!(opts.keydb_path.is_none());
}
#[test]
fn scan_options_with_keydb() {
let opts = ScanOptions::with_keydb("/tmp/KEYDB.cfg");
assert_eq!(
opts.keydb_path.as_ref().unwrap().to_str().unwrap(),
"/tmp/KEYDB.cfg"
);
}
#[test]
fn scan_options_with_keydb_pathbuf() {
let path = std::path::PathBuf::from("/home/user/.config/aacs/KEYDB.cfg");
let opts = ScanOptions::with_keydb(path.clone());
assert_eq!(opts.keydb_path.unwrap(), path);
}
+182
View File
@@ -347,3 +347,185 @@ fn disc_title_empty() {
assert_eq!(dt.duration_secs, 0.0);
assert!(dt.playlist.is_empty());
}
// ── Meta codec roundtrip ─────────────────────────────────────
#[test]
fn meta_codec_roundtrip() {
// Test that all codec types survive from_title -> to_title
let codecs_video = &[Codec::Hevc, Codec::H264, Codec::Vc1, Codec::Mpeg2];
let codecs_audio = &[Codec::Ac3, Codec::Ac3Plus, Codec::TrueHd, Codec::DtsHdMa, Codec::DtsHdHr, Codec::Dts, Codec::Lpcm];
let codecs_sub = &[Codec::Pgs];
let mut streams = Vec::new();
for (i, &codec) in codecs_video.iter().enumerate() {
streams.push(Stream::Video(VideoStream {
pid: (0x1011 + i) as u16, codec,
resolution: "1080p".into(), frame_rate: "23.976".into(),
hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709,
secondary: false, label: String::new(),
}));
}
for (i, &codec) in codecs_audio.iter().enumerate() {
streams.push(Stream::Audio(AudioStream {
pid: (0x1100 + i) as u16, codec,
channels: "5.1".into(), language: "eng".into(),
sample_rate: "48kHz".into(), secondary: false,
label: String::new(),
}));
}
for (i, &codec) in codecs_sub.iter().enumerate() {
streams.push(Stream::Subtitle(SubtitleStream {
pid: (0x1200 + i) as u16, codec,
language: "eng".into(), forced: false,
}));
}
let dt = DiscTitle {
playlist: "Codec Test".into(),
playlist_id: 0,
duration_secs: 100.0,
size_bytes: 0,
clips: Vec::new(),
streams,
extents: Vec::new(),
};
let meta = M2tsMeta::from_title(&dt);
let restored = meta.to_title();
assert_eq!(restored.streams.len(), dt.streams.len());
for (orig, rest) in dt.streams.iter().zip(restored.streams.iter()) {
match (orig, rest) {
(Stream::Video(o), Stream::Video(r)) => assert_eq!(o.codec, r.codec, "video codec mismatch"),
(Stream::Audio(o), Stream::Audio(r)) => assert_eq!(o.codec, r.codec, "audio codec mismatch"),
(Stream::Subtitle(o), Stream::Subtitle(r)) => assert_eq!(o.codec, r.codec, "subtitle codec mismatch"),
_ => panic!("stream type mismatch"),
}
}
}
#[test]
fn meta_empty_streams() {
let dt = DiscTitle {
playlist: "Empty".into(),
playlist_id: 0,
duration_secs: 0.0,
size_bytes: 0,
clips: Vec::new(),
streams: Vec::new(),
extents: Vec::new(),
};
let meta = M2tsMeta::from_title(&dt);
assert_eq!(meta.streams.len(), 0);
let restored = meta.to_title();
assert_eq!(restored.streams.len(), 0);
assert_eq!(restored.playlist, "Empty");
}
#[test]
fn meta_all_stream_types() {
let dt = DiscTitle {
playlist: "Full".into(),
playlist_id: 0,
duration_secs: 3600.0,
size_bytes: 0,
clips: Vec::new(),
streams: vec![
Stream::Video(VideoStream {
pid: 0x1011, codec: Codec::Hevc,
resolution: "2160p".into(), frame_rate: "23.976".into(),
hdr: HdrFormat::Hdr10, color_space: ColorSpace::Bt709,
secondary: false, label: "Primary".into(),
}),
Stream::Audio(AudioStream {
pid: 0x1100, codec: Codec::TrueHd,
channels: "7.1".into(), language: "eng".into(),
sample_rate: "48kHz".into(), secondary: false,
label: "Primary Audio".into(),
}),
Stream::Subtitle(SubtitleStream {
pid: 0x1200, codec: Codec::Pgs,
language: "fra".into(), forced: true,
}),
Stream::Audio(AudioStream {
pid: 0x1110, codec: Codec::Ac3,
channels: "stereo".into(), language: "eng".into(),
sample_rate: "48kHz".into(), secondary: true,
label: "Commentary".into(),
}),
],
extents: Vec::new(),
};
let meta = M2tsMeta::from_title(&dt);
let restored = meta.to_title();
assert_eq!(restored.streams.len(), 4);
// Video preserved
if let Stream::Video(v) = &restored.streams[0] {
assert_eq!(v.codec, Codec::Hevc);
assert_eq!(v.resolution, "2160p");
assert_eq!(v.label, "Primary");
assert!(!v.secondary);
} else { panic!("expected video"); }
// Primary audio preserved
if let Stream::Audio(a) = &restored.streams[1] {
assert_eq!(a.codec, Codec::TrueHd);
assert_eq!(a.channels, "7.1");
assert!(!a.secondary);
} else { panic!("expected audio"); }
// Subtitle preserved (forced flag)
if let Stream::Subtitle(s) = &restored.streams[2] {
assert_eq!(s.language, "fra");
assert!(s.forced);
} else { panic!("expected subtitle"); }
// Secondary audio preserved
if let Stream::Audio(a) = &restored.streams[3] {
assert_eq!(a.codec, Codec::Ac3);
assert!(a.secondary);
assert_eq!(a.label, "Commentary");
} else { panic!("expected secondary audio"); }
}
// ── MkvStream tests ──────────────────────────────────────────
#[test]
fn mkvstream_write_finish() {
let output = Cursor::new(Vec::new());
let dt = sample_disc_title();
let mut stream = MkvStream::new(output).meta(&dt).max_buffer(1024 * 1024);
// Write some fake BD-TS packets (they won't produce valid MKV frames
// since there is no real codec data, but it should not panic)
for i in 0..20u8 {
let mut pkt = [0u8; 192];
pkt[4] = 0x47;
// PID 0x1011 (video)
pkt[5] = 0x10;
pkt[6] = 0x11;
pkt[7] = 0x10;
pkt[8] = i;
stream.write_all(&pkt).unwrap();
}
// finish should not panic even without valid codec data
stream.finish().unwrap();
}
#[test]
fn mkvstream_meta_sets_title() {
let output = Cursor::new(Vec::new());
let dt = sample_disc_title();
let stream = MkvStream::new(output).meta(&dt);
let info = stream.info();
assert_eq!(info.playlist, "Test Movie");
assert_eq!(info.duration_secs, 7200.0);
assert_eq!(info.streams.len(), 4);
}
+458
View File
@@ -0,0 +1,458 @@
//! UDF parser tests using a MockSectorReader.
use std::collections::HashMap;
use libfreemkv::error::Result;
use libfreemkv::sector::SectorReader;
use libfreemkv::udf;
const SECTOR_SIZE: usize = 2048;
/// In-memory sector reader backed by a HashMap<LBA, sector_data>.
/// Any LBA not in the map returns zeroed sectors.
struct MockSectorReader {
sectors: HashMap<u32, Vec<u8>>,
}
impl MockSectorReader {
fn new() -> Self {
Self { sectors: HashMap::new() }
}
/// Write a full 2048-byte sector at the given LBA.
fn set_sector(&mut self, lba: u32, data: Vec<u8>) {
assert_eq!(data.len(), SECTOR_SIZE, "sector data must be exactly 2048 bytes");
self.sectors.insert(lba, data);
}
/// Write partial data into a sector (rest is zeroed).
fn set_sector_partial(&mut self, lba: u32, data: &[u8]) {
let mut sector = vec![0u8; SECTOR_SIZE];
let len = data.len().min(SECTOR_SIZE);
sector[..len].copy_from_slice(&data[..len]);
self.sectors.insert(lba, sector);
}
}
impl SectorReader for MockSectorReader {
fn read_sectors(&mut self, lba: u32, count: u16, buf: &mut [u8]) -> Result<usize> {
let total = count as usize * SECTOR_SIZE;
assert!(buf.len() >= total, "buffer too small");
for i in 0..count as u32 {
let offset = i as usize * SECTOR_SIZE;
if let Some(data) = self.sectors.get(&(lba + i)) {
buf[offset..offset + SECTOR_SIZE].copy_from_slice(data);
} else {
// Return zeros for unmapped sectors
buf[offset..offset + SECTOR_SIZE].fill(0);
}
}
Ok(total)
}
}
// ── Helper: build raw sector data ──────────────────────────────────────────
/// Build an AVDP sector (tag_id=2) pointing to VDS at the given LBA.
fn make_avdp_sector(vds_lba: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
// Tag ID = 2 (AVDP) at bytes [0..2]
s[0..2].copy_from_slice(&2u16.to_le_bytes());
// Main VDS extent location at bytes [16..20]
s[16..20].copy_from_slice(&vds_lba.to_le_bytes());
// Main VDS extent length at bytes [20..24] (arbitrary, say 6 sectors)
s[20..24].copy_from_slice(&(6u32 * SECTOR_SIZE as u32).to_le_bytes());
s
}
/// Build a Primary Volume Descriptor (tag_id=1) with the given volume ID.
fn make_pvd_sector(volume_id: &str) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&1u16.to_le_bytes());
// Volume ID at offset 24, d-string format:
// compression_id(1 byte) + ASCII chars + length byte at position 55
if !volume_id.is_empty() {
let id_bytes = volume_id.as_bytes();
s[24] = 8; // compression ID = ASCII
let copy_len = id_bytes.len().min(30);
s[25..25 + copy_len].copy_from_slice(&id_bytes[..copy_len]);
// d-string length byte at end of 32-byte field (offset 55)
s[55] = (1 + copy_len) as u8; // compression byte + chars
}
s
}
/// Build a Partition Descriptor (tag_id=5) with partition_start.
fn make_partition_desc(partition_start: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&5u16.to_le_bytes());
// Partition start at bytes [188..192]
s[188..192].copy_from_slice(&partition_start.to_le_bytes());
s
}
/// Build a Logical Volume Descriptor (tag_id=6) with a single partition map
/// (no metadata partition -- simplest case).
fn make_lvd_sector_simple() -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&6u16.to_le_bytes());
// num_partition_maps at bytes [268..272] = 1 (no metadata partition)
s[268..272].copy_from_slice(&1u32.to_le_bytes());
s
}
/// Build a Terminating Descriptor (tag_id=8).
fn make_terminator() -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&8u16.to_le_bytes());
s
}
/// Build a File Set Descriptor (tag_id=256) with root ICB at the given meta LBA.
fn make_fsd_sector(root_meta_lba: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&256u16.to_le_bytes());
// Root directory ICB long_ad at offset 400:
// [400..404] = extent_length (one sector = 2048)
s[400..404].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
// [404..408] = extent_location (LBA within metadata partition)
s[404..408].copy_from_slice(&root_meta_lba.to_le_bytes());
s
}
/// Build an Extended File Entry (tag_id=266) for a directory with the given
/// allocation extent pointing to directory data.
fn make_dir_icb(data_meta_lba: u32, data_len: u32) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&266u16.to_le_bytes());
// info_length at offset 56 (u64)
s[56..64].copy_from_slice(&(data_len as u64).to_le_bytes());
// L_EA at offset 208 = 0 (no extended attributes)
s[208..212].copy_from_slice(&0u32.to_le_bytes());
// L_AD at offset 212 = 8 (one short allocation descriptor)
s[212..216].copy_from_slice(&8u32.to_le_bytes());
// Short allocation descriptor at offset 216:
// [216..220] = extent_length (type=0 in top 2 bits, length in low 30 bits)
s[216..220].copy_from_slice(&data_len.to_le_bytes());
// [220..224] = extent_position (LBA within metadata partition)
s[220..224].copy_from_slice(&data_meta_lba.to_le_bytes());
s
}
/// Build a File Identifier Descriptor (tag_id=257) for a named entry.
/// Returns raw bytes (not padded to full sector).
fn make_fid(name: &str, icb_meta_lba: u32, is_dir: bool) -> Vec<u8> {
// FID name: compression_id(1) + ASCII bytes
let mut name_bytes = vec![8u8]; // compression ID = 8 (ASCII)
name_bytes.extend_from_slice(name.as_bytes());
let l_fi = name_bytes.len() as u8;
let file_chars: u8 = if is_dir { 0x02 } else { 0x00 };
// Fixed header = 38 bytes, L_IU = 0
let fid_len = ((38 + 0 + l_fi as usize + 3) & !3) as usize; // 4-byte aligned
let mut fid = vec![0u8; fid_len];
// tag_id = 257
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
// file_characteristics at offset 18
fid[18] = file_chars;
// L_FI at offset 19
fid[19] = l_fi;
// ICB long_ad at offset 20:
// [20..24] = extent_length (2048)
fid[20..24].copy_from_slice(&(SECTOR_SIZE as u32).to_le_bytes());
// [24..28] = extent_location (LBA)
fid[24..28].copy_from_slice(&icb_meta_lba.to_le_bytes());
// L_IU at offset 36 = 0
fid[36..38].copy_from_slice(&0u16.to_le_bytes());
// Name starts at offset 38
fid[38..38 + name_bytes.len()].copy_from_slice(&name_bytes);
fid
}
/// Build a parent FID (file_chars = 0x08, no name).
fn make_parent_fid() -> Vec<u8> {
let fid_len = ((38 + 0 + 0 + 3) & !3) as usize;
let mut fid = vec![0u8; fid_len];
fid[0..2].copy_from_slice(&257u16.to_le_bytes());
fid[18] = 0x08; // parent
fid[19] = 0; // L_FI = 0
fid
}
/// Build a File Entry ICB (tag_id=261) for a file, with the given extent.
fn make_file_icb(data_lba: u32, data_len: u32, file_size: u64) -> Vec<u8> {
let mut s = vec![0u8; SECTOR_SIZE];
s[0..2].copy_from_slice(&261u16.to_le_bytes());
// info_length at offset 56 (u64)
s[56..64].copy_from_slice(&file_size.to_le_bytes());
// L_EA at offset 168 = 0
s[168..172].copy_from_slice(&0u32.to_le_bytes());
// L_AD at offset 172 = 8
s[172..176].copy_from_slice(&8u32.to_le_bytes());
// Short allocation descriptor at offset 176:
s[176..180].copy_from_slice(&data_len.to_le_bytes());
s[180..184].copy_from_slice(&data_lba.to_le_bytes());
s
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[test]
fn mock_sector_reader_roundtrip() {
let mut reader = MockSectorReader::new();
// Write a recognizable pattern to sector 100
let mut data = vec![0u8; SECTOR_SIZE];
data[0] = 0xAB;
data[1] = 0xCD;
data[2047] = 0xFF;
reader.set_sector(100, data.clone());
// Read it back
let mut buf = vec![0u8; SECTOR_SIZE];
let n = reader.read_sectors(100, 1, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 0xAB);
assert_eq!(buf[1], 0xCD);
assert_eq!(buf[2047], 0xFF);
// Reading an unmapped sector returns zeros
let mut buf2 = vec![0xFFu8; SECTOR_SIZE];
let n2 = reader.read_sectors(999, 1, &mut buf2).unwrap();
assert_eq!(n2, SECTOR_SIZE);
assert_eq!(buf2[0], 0);
assert_eq!(buf2[2047], 0);
}
#[test]
fn mock_sector_reader_multi_sector() {
let mut reader = MockSectorReader::new();
let mut s10 = vec![0u8; SECTOR_SIZE];
s10[0] = 10;
reader.set_sector(10, s10);
let mut s11 = vec![0u8; SECTOR_SIZE];
s11[0] = 11;
reader.set_sector(11, s11);
// Read 2 consecutive sectors
let mut buf = vec![0u8; SECTOR_SIZE * 2];
let n = reader.read_sectors(10, 2, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE * 2);
assert_eq!(buf[0], 10);
assert_eq!(buf[SECTOR_SIZE], 11);
}
#[test]
fn read_filesystem_no_avdp() {
// Empty reader — sector 256 is all zeros, tag_id=0 != 2
let mut reader = MockSectorReader::new();
let result = udf::read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when no AVDP at sector 256");
}
#[test]
fn read_filesystem_bad_avdp_tag() {
// Put a sector at 256 with wrong tag_id
let mut reader = MockSectorReader::new();
let mut bad = vec![0u8; SECTOR_SIZE];
bad[0..2].copy_from_slice(&99u16.to_le_bytes()); // tag_id=99, not 2
reader.set_sector(256, bad);
let result = udf::read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when AVDP tag_id is not 2");
}
#[test]
fn read_filesystem_no_partition_descriptor() {
// Valid AVDP but VDS has no Partition Descriptor (tag 5)
let mut reader = MockSectorReader::new();
reader.set_sector(256, make_avdp_sector(32));
// Put a terminator immediately at sector 32
reader.set_sector(32, make_terminator());
let result = udf::read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when no partition descriptor in VDS");
}
#[test]
fn read_filesystem_bad_fsd_tag() {
// Valid AVDP + VDS with partition desc + LVD, but FSD at metadata_start has wrong tag
let mut reader = MockSectorReader::new();
let partition_start = 512;
reader.set_sector(256, make_avdp_sector(32));
reader.set_sector(32, make_pvd_sector("TEST_DISC"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// With 1 partition map, metadata_start = partition_start.
// FSD should be at sector partition_start but we leave it as zeros (tag_id=0 != 256).
let result = udf::read_filesystem(&mut reader);
assert!(result.is_err(), "should fail when FSD tag_id is not 256");
}
#[test]
fn read_filesystem_minimal_valid() {
// Build a minimal valid UDF image: AVDP -> VDS -> FSD -> empty root dir
let mut reader = MockSectorReader::new();
let partition_start: u32 = 512;
let root_icb_meta_lba: u32 = 1; // relative to metadata_start
let root_data_meta_lba: u32 = 2;
// Sector 256: AVDP
reader.set_sector(256, make_avdp_sector(32));
// VDS at sectors 32..35
reader.set_sector(32, make_pvd_sector("MY_DISC"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// FSD at partition_start (since single partition map, metadata_start = partition_start)
reader.set_sector(partition_start, make_fsd_sector(root_icb_meta_lba));
// Root directory ICB at metadata_start + root_icb_meta_lba
// Points to directory data at root_data_meta_lba, length = 40 (one parent FID)
let parent_fid = make_parent_fid();
let dir_data_len = parent_fid.len() as u32;
reader.set_sector(
partition_start + root_icb_meta_lba,
make_dir_icb(root_data_meta_lba, dir_data_len),
);
// Root directory data: just a parent FID (empty directory)
reader.set_sector_partial(partition_start + root_data_meta_lba, &parent_fid);
let fs = udf::read_filesystem(&mut reader).expect("should parse minimal UDF");
assert_eq!(fs.volume_id, "MY_DISC");
assert!(fs.root.is_dir);
assert!(fs.root.entries.is_empty(), "root should have no children");
}
#[test]
fn read_filesystem_with_subdirectory() {
// Build a UDF image with root -> BDMV (dir) -> test.mpls (file)
let mut reader = MockSectorReader::new();
let partition_start: u32 = 512;
// Layout (all relative to partition_start which equals metadata_start):
// meta LBA 0 = FSD
// meta LBA 1 = root ICB
// meta LBA 2 = root dir data
// meta LBA 3 = BDMV ICB
// meta LBA 4 = BDMV dir data
// meta LBA 5 = test.mpls file ICB
// meta LBA 10 = test.mpls file data (partition-relative)
reader.set_sector(256, make_avdp_sector(32));
reader.set_sector(32, make_pvd_sector("DISC_WITH_BDMV"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// FSD -> root ICB at meta LBA 1
reader.set_sector(partition_start + 0, make_fsd_sector(1));
// Root dir: parent FID + BDMV dir FID
let parent_fid = make_parent_fid();
let bdmv_fid = make_fid("BDMV", 3, true);
let mut root_data = Vec::new();
root_data.extend_from_slice(&parent_fid);
root_data.extend_from_slice(&bdmv_fid);
let root_data_len = root_data.len() as u32;
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data_len));
reader.set_sector_partial(partition_start + 2, &root_data);
// BDMV dir: parent FID + test.mpls file FID
let bdmv_parent = make_parent_fid();
let mpls_fid = make_fid("test.mpls", 5, false);
let mut bdmv_data = Vec::new();
bdmv_data.extend_from_slice(&bdmv_parent);
bdmv_data.extend_from_slice(&mpls_fid);
let bdmv_data_len = bdmv_data.len() as u32;
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data_len));
reader.set_sector_partial(partition_start + 4, &bdmv_data);
// test.mpls file ICB (File Entry tag 261)
reader.set_sector(partition_start + 5, make_file_icb(10, 1024, 1024));
let fs = udf::read_filesystem(&mut reader).expect("should parse UDF with subdir");
assert_eq!(fs.volume_id, "DISC_WITH_BDMV");
// Root should have one child: BDMV
assert_eq!(fs.root.entries.len(), 1);
let bdmv = &fs.root.entries[0];
assert_eq!(bdmv.name, "BDMV");
assert!(bdmv.is_dir);
// BDMV should have one child: test.mpls
assert_eq!(bdmv.entries.len(), 1);
let mpls = &bdmv.entries[0];
assert_eq!(mpls.name, "test.mpls");
assert!(!mpls.is_dir);
assert_eq!(mpls.size, 1024);
}
#[test]
fn find_dir_case_insensitive() {
// Build a UDF image with BDMV/PLAYLIST directories, then search with various cases
let mut reader = MockSectorReader::new();
let partition_start: u32 = 512;
reader.set_sector(256, make_avdp_sector(32));
reader.set_sector(32, make_pvd_sector("CASE_TEST"));
reader.set_sector(33, make_partition_desc(partition_start));
reader.set_sector(34, make_lvd_sector_simple());
reader.set_sector(35, make_terminator());
// FSD
reader.set_sector(partition_start + 0, make_fsd_sector(1));
// Root -> BDMV
let root_data = [make_parent_fid(), make_fid("BDMV", 3, true)].concat();
reader.set_sector(partition_start + 1, make_dir_icb(2, root_data.len() as u32));
reader.set_sector_partial(partition_start + 2, &root_data);
// BDMV -> PLAYLIST
let bdmv_data = [make_parent_fid(), make_fid("PLAYLIST", 5, true)].concat();
reader.set_sector(partition_start + 3, make_dir_icb(4, bdmv_data.len() as u32));
reader.set_sector_partial(partition_start + 4, &bdmv_data);
// PLAYLIST (empty)
let playlist_data = make_parent_fid();
reader.set_sector(partition_start + 5, make_dir_icb(6, playlist_data.len() as u32));
reader.set_sector_partial(partition_start + 6, &playlist_data);
let fs = udf::read_filesystem(&mut reader).expect("should parse");
// Exact case
assert!(fs.find_dir("BDMV/PLAYLIST").is_some());
// Lower case
assert!(fs.find_dir("bdmv/playlist").is_some());
// Mixed case
assert!(fs.find_dir("Bdmv/Playlist").is_some());
assert!(fs.find_dir("BdMv/PlayList").is_some());
// Leading/trailing slashes
assert!(fs.find_dir("/BDMV/PLAYLIST/").is_some());
// Nonexistent
assert!(fs.find_dir("BDMV/STREAM").is_none());
assert!(fs.find_dir("NONEXISTENT").is_none());
}
#[test]
fn sector_reader_is_object_safe() {
// Verify SectorReader can be used as a trait object
let mut reader = MockSectorReader::new();
reader.set_sector(0, vec![42u8; SECTOR_SIZE]);
let dyn_reader: &mut dyn SectorReader = &mut reader;
let mut buf = vec![0u8; SECTOR_SIZE];
let n = dyn_reader.read_sectors(0, 1, &mut buf).unwrap();
assert_eq!(n, SECTOR_SIZE);
assert_eq!(buf[0], 42);
}