Skip to main content

mpeg_ts/
mux.rs

1//! Section → TS packetiser (the byte-exact inverse of
2//! [`SectionReassembler::feed`](crate::ts::SectionReassembler::feed)).
3//!
4//! Per the PSI carriage rules of ITU-T H.222.0 §2.4.4 (= ISO/IEC 13818-1:2007 §2.4.4)
5//! (`mpeg-ts/docs/tables/h222_0/psi_section.md`): sections are packed into 188-byte packets
6//! with a `pointer_field` where sections begin, concatenated contiguously, and
7//! 0xFF-stuffed at the batch tail.
8
9use alloc::vec::Vec;
10use core::time::Duration;
11
12use crate::pid::well_known;
13use crate::ts::{CC_MASK, TS_PACKET_SIZE, TsHeader};
14
15/// Maximum data bytes in a PUSI=1 packet (188 − 4 header − 1 pointer_field). §2.4.4.
16const PUSI_PAYLOAD_CAP: usize = 183;
17/// Maximum data bytes in a continuation packet (188 − 4 header). §2.4.4.
18const PAYLOAD_CAP: usize = 184;
19/// Stuffing byte for unused TS payload bytes (ISO/IEC 13818-1 §2.4.4).
20const STUFFING_BYTE: u8 = 0xFF;
21
22/// Packetises PSI/SI sections into 188-byte TS packets.
23///
24/// This is the byte-exact inverse of
25/// [`SectionReassembler::feed`](crate::ts::SectionReassembler::feed): packets
26/// produced here, when fed back through the reassembler, yield the same
27/// sections in order.
28///
29/// ISO/IEC 13818-1:2007 §2.4.4 (`mpeg-ts/docs/tables/h222_0/psi_section.md`).
30pub struct SectionPacketiser {
31    pid: u16,
32    continuity_counter: u8,
33}
34
35impl SectionPacketiser {
36    /// Start a packetiser for `pid` with continuity_counter = 0.
37    pub fn new(pid: u16) -> Self {
38        Self {
39            pid,
40            continuity_counter: 0,
41        }
42    }
43
44    /// Start at a specific continuity_counter (0..=15) — for resuming a stream.
45    pub fn with_continuity(pid: u16, cc: u8) -> Self {
46        Self {
47            pid,
48            continuity_counter: cc & CC_MASK,
49        }
50    }
51
52    /// The PID this packetiser emits packets for.
53    pub fn pid(&self) -> u16 {
54        self.pid
55    }
56
57    /// The continuity_counter for the next emitted packet.
58    pub fn continuity_counter(&self) -> u8 {
59        self.continuity_counter
60    }
61
62    /// Packetise a batch of complete sections into 188-byte TS packets,
63    /// appended to `out` (cleared first).
64    ///
65    /// Returns the number of packets appended.
66    pub fn packetise_into(
67        &mut self,
68        sections: &[&[u8]],
69        out: &mut Vec<[u8; TS_PACKET_SIZE]>,
70    ) -> usize {
71        out.clear();
72
73        if sections.is_empty() {
74            return 0;
75        }
76
77        // Concatenate all sections and record section-start byte offsets.
78        let total_len: usize = sections.iter().map(|s| s.len()).sum();
79        if total_len == 0 {
80            return 0;
81        }
82        let mut data = Vec::with_capacity(total_len);
83        let mut starts = Vec::with_capacity(sections.len());
84        for s in sections {
85            starts.push(data.len());
86            data.extend_from_slice(s);
87        }
88
89        let count_before = out.len();
90        let mut pos = 0usize;
91        // `starts` is ascending and `pos` only advances, so a forward-only
92        // cursor finds the smallest section-start offset >= pos in amortised
93        // O(1) per packet instead of rescanning from the front (audit
94        // r01-O1: O(packets x sections)).
95        let mut next_idx = 0usize;
96
97        while pos < data.len() {
98            while starts.get(next_idx).is_some_and(|&s| s < pos) {
99                next_idx += 1;
100            }
101            let next_start = starts.get(next_idx).copied();
102
103            let pusi: bool;
104            let pointer_field: u8;
105            let cap: usize;
106
107            if let Some(ns) = next_start {
108                let diff = ns.saturating_sub(pos);
109                // W1 (#1074): `diff == PUSI_PAYLOAD_CAP` (183) must NOT take the
110                // PUSI branch — a pointer_field of 183 would claim a section
111                // starts after all 183 payload bytes, i.e. not within this
112                // packet at all (H.222.0 §2.4.4 requires PUSI=1 to mean a
113                // section genuinely starts here). The non-PUSI branch's cap
114                // must in turn be bounded by `diff`, not just the fixed
115                // 184-byte payload size, or a continuation packet at this same
116                // boundary copies one byte past the next section's start —
117                // the reassembler then silently drops it (it only takes what
118                // completes the in-progress section), corrupting the next one.
119                if diff < PUSI_PAYLOAD_CAP {
120                    pusi = true;
121                    pointer_field = diff as u8;
122                    cap = PUSI_PAYLOAD_CAP;
123                } else {
124                    pusi = false;
125                    pointer_field = 0;
126                    cap = PAYLOAD_CAP.min(diff);
127                }
128            } else {
129                pusi = false;
130                pointer_field = 0;
131                cap = PAYLOAD_CAP;
132            }
133
134            let mut pkt = [0u8; TS_PACKET_SIZE];
135
136            let header = TsHeader {
137                tei: false,
138                pusi,
139                pid: self.pid,
140                scrambling: 0,
141                has_adaptation: false,
142                has_payload: true,
143                continuity_counter: self.continuity_counter,
144            };
145            header
146                .serialize_into(&mut pkt[..4])
147                .expect("4-byte header buffer");
148
149            self.continuity_counter = (self.continuity_counter + 1) & CC_MASK;
150
151            let mut write_pos = 4usize;
152
153            if pusi {
154                pkt[write_pos] = pointer_field;
155                write_pos += 1;
156            }
157
158            let remaining = data.len() - pos;
159            let to_copy = remaining.min(cap);
160            pkt[write_pos..write_pos + to_copy].copy_from_slice(&data[pos..pos + to_copy]);
161            pos += to_copy;
162            write_pos += to_copy;
163
164            // 0xFF-stuff remaining payload bytes.
165            for b in &mut pkt[write_pos..] {
166                *b = STUFFING_BYTE;
167            }
168
169            out.push(pkt);
170        }
171
172        out.len() - count_before
173    }
174
175    /// Allocating convenience wrapper over [`packetise_into`](Self::packetise_into).
176    pub fn packetise(&mut self, sections: &[&[u8]]) -> Vec<[u8; TS_PACKET_SIZE]> {
177        let mut out = Vec::new();
178        self.packetise_into(sections, &mut out);
179        out
180    }
181}
182
183// ── Default interval constants ────────────────────────────────────────────────
184
185/// dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2 — NIT maximum interval.
186pub const NIT_MAX_INTERVAL: Duration = Duration::from_secs(10);
187/// dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2 — BAT maximum interval.
188pub const BAT_MAX_INTERVAL: Duration = Duration::from_secs(10);
189/// dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2 — SDT actual maximum interval.
190pub const SDT_ACTUAL_MAX_INTERVAL: Duration = Duration::from_secs(2);
191/// dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2 — SDT other maximum interval.
192pub const SDT_OTHER_MAX_INTERVAL: Duration = Duration::from_secs(10);
193/// dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2 — EIT p/f actual maximum interval.
194pub const EIT_PF_ACTUAL_MAX_INTERVAL: Duration = Duration::from_secs(2);
195/// dvb-si/docs/tr_101_211.md §4.4.1 — EIT p/f other maximum interval (sat/cable;
196/// terrestrial is 20 s per §4.4.2).
197pub const EIT_PF_OTHER_MAX_INTERVAL: Duration = Duration::from_secs(10);
198/// dvb-si/docs/tr_101_211.md §4.4.1 — EIT schedule first 8 days maximum interval.
199pub const EIT_SCHED_MAX_INTERVAL: Duration = Duration::from_secs(10);
200/// dvb-si/docs/tr_101_211.md §4.4.1 — EIT schedule beyond 8 days maximum interval.
201pub const EIT_SCHED_EXT_MAX_INTERVAL: Duration = Duration::from_secs(30);
202/// dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2 — TDT maximum interval.
203pub const TDT_MAX_INTERVAL: Duration = Duration::from_secs(30);
204/// dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2 — TOT maximum interval.
205pub const TOT_MAX_INTERVAL: Duration = Duration::from_secs(30);
206
207/// dvb-si/docs/ts_101_154_av_coding.md §4.1.7 — PAT maximum interval (shall ≤ 100 ms).
208pub const PAT_MAX_INTERVAL: Duration = Duration::from_millis(100);
209/// dvb-si/docs/ts_101_154_av_coding.md §4.1.7 — PMT maximum interval (shall ≤ 100 ms).
210pub const PMT_MAX_INTERVAL: Duration = Duration::from_millis(100);
211
212/// dvb-si/docs/tables/en_300_468/514-repetition-rates-and-random-access-hand-transcribed.md
213/// §5.1.4.1 — minimum inter-section interval floor (≤100 Mbit/s TSs).
214pub const MIN_SECTION_INTERVAL: Duration = Duration::from_millis(25);
215
216// ── SiMux ────────────────────────────────────────────────────────────────────
217
218/// Section-repetition scheduler that builds TS packets on a caller-supplied clock.
219///
220/// Each entry is a PID + table_id + concatenated complete-section bytes + an
221/// emission interval. Call [`poll_into`](Self::poll_into) with
222/// monotonically-increasing `now` values to get 188-byte TS packets for every
223/// entry whose interval has elapsed since its last emission.
224///
225/// The scheduler owns its section bytes — call [`upsert`](Self::upsert) to
226/// update them when SI changes. Entries are keyed by `(pid, table_id)`, not
227/// PID alone: several distinct tables share a PID by spec (TDT/TOT both on
228/// `0x0014`, SDT/BAT both on `0x0011`, EIT present/following vs. schedule
229/// sub-tables on `0x0012`), and `table_id` — the first byte of `sections` —
230/// is exactly the field the spec uses to tell them apart, so `upsert_tot`
231/// cannot silently replace a previously-registered `upsert_tdt` entry.
232///
233/// All entries on the same PID share one [`SectionPacketiser`] (and so one
234/// continuity counter), created lazily the first time that PID emits — this
235/// is what keeps the continuity counter continuous per PID across table
236/// kinds, not just within one kind.
237///
238/// # 25 ms floor
239///
240/// [`MIN_SECTION_INTERVAL`] is the minimum valid interval (EN 300 468 §5.1.4.1).
241/// Supplying an interval below this in [`upsert`](Self::upsert) triggers a
242/// `debug_assert` — in release builds the assertion compiles out; the caller
243/// must ensure compliance.
244pub struct SiMux {
245    entries: Vec<Entry>,
246    /// One packetiser per distinct PID, shared across every table_id on it.
247    packetisers: Vec<SectionPacketiser>,
248}
249
250struct Entry {
251    pid: u16,
252    table_id: u8,
253    sections: Vec<u8>,
254    interval: Duration,
255    last_emit: Option<Duration>,
256}
257
258/// The dedupe/lookup key for an entry: the first byte of `sections` is
259/// `table_id` for every PSI/SI section (ISO/IEC 13818-1 §2.4.4.4 / EN 300 468
260/// §5.2). An empty `sections` (no section registered yet) has no real
261/// table_id; `0` is used as a sentinel and can only collide with another
262/// empty registration on the same PID, which is a caller no-op either way.
263fn table_id_of(sections: &[u8]) -> u8 {
264    sections.first().copied().unwrap_or(0)
265}
266
267/// Find the packetiser for `pid`, creating one (fresh continuity counter) if
268/// this is the first entry ever registered on that PID. A free function, not
269/// a method, so callers can hold `&mut entries` and this borrow concurrently.
270fn packetiser_for(packetisers: &mut Vec<SectionPacketiser>, pid: u16) -> &mut SectionPacketiser {
271    if let Some(idx) = packetisers.iter().position(|p| p.pid() == pid) {
272        &mut packetisers[idx]
273    } else {
274        packetisers.push(SectionPacketiser::new(pid));
275        packetisers.last_mut().expect("just pushed")
276    }
277}
278
279impl SiMux {
280    /// Create an empty scheduler (no entries, no pending emissions).
281    pub fn new() -> Self {
282        Self {
283            entries: Vec::new(),
284            packetisers: Vec::new(),
285        }
286    }
287
288    /// Register or replace the sections emitted on `pid` at `interval`.
289    ///
290    /// `sections` is the concatenated complete-section bytes for one emission
291    /// cycle; its first byte (`table_id`) plus `pid` is the dedupe key, so
292    /// re-calling for the same `(pid, table_id)` updates the bytes and
293    /// interval of that one table while preserving the PID's shared
294    /// continuity counter — a *different* table_id on the same `pid` (e.g.
295    /// TOT after TDT, both on `0x0014`) becomes a second, independent entry.
296    ///
297    /// # Panics (debug only)
298    ///
299    /// `debug_assert!(interval >= MIN_SECTION_INTERVAL)` — EN 300 468 §5.1.4.1
300    /// requires a minimum inter-section interval of 25 ms.
301    pub fn upsert(&mut self, pid: u16, sections: Vec<u8>, interval: Duration) {
302        debug_assert!(
303            interval >= MIN_SECTION_INTERVAL,
304            "interval {interval:?} is below the 25 ms minimum (EN 300 468 §5.1.4.1)"
305        );
306
307        let table_id = table_id_of(&sections);
308        if let Some(entry) = self
309            .entries
310            .iter_mut()
311            .find(|e| e.pid == pid && e.table_id == table_id)
312        {
313            entry.sections = sections;
314            entry.interval = interval;
315        } else {
316            self.entries.push(Entry {
317                pid,
318                table_id,
319                sections,
320                interval,
321                last_emit: None,
322            });
323        }
324    }
325
326    /// PAT at [`PAT_MAX_INTERVAL`] on PID 0x0000.
327    ///
328    /// Interval cites dvb-si/docs/ts_101_154_av_coding.md §4.1.7.
329    pub fn upsert_pat(&mut self, sections: Vec<u8>) {
330        self.upsert(well_known::PAT.value(), sections, PAT_MAX_INTERVAL);
331    }
332
333    /// PMT at [`PMT_MAX_INTERVAL`] on the caller-supplied `pid`.
334    ///
335    /// Interval cites dvb-si/docs/ts_101_154_av_coding.md §4.1.7.
336    pub fn upsert_pmt(&mut self, pid: u16, sections: Vec<u8>) {
337        self.upsert(pid, sections, PMT_MAX_INTERVAL);
338    }
339
340    /// SDT actual at [`SDT_ACTUAL_MAX_INTERVAL`] on PID 0x0011.
341    ///
342    /// Interval cites dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2.
343    pub fn upsert_sdt_actual(&mut self, sections: Vec<u8>) {
344        self.upsert(
345            well_known::SDT_BAT.value(),
346            sections,
347            SDT_ACTUAL_MAX_INTERVAL,
348        );
349    }
350
351    /// NIT at [`NIT_MAX_INTERVAL`] on PID 0x0010.
352    ///
353    /// Interval cites dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2.
354    pub fn upsert_nit(&mut self, sections: Vec<u8>) {
355        self.upsert(well_known::NIT.value(), sections, NIT_MAX_INTERVAL);
356    }
357
358    /// TDT at [`TDT_MAX_INTERVAL`] on PID 0x0014.
359    ///
360    /// Interval cites dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2.
361    pub fn upsert_tdt(&mut self, sections: Vec<u8>) {
362        self.upsert(well_known::TDT_TOT.value(), sections, TDT_MAX_INTERVAL);
363    }
364
365    /// TOT at [`TOT_MAX_INTERVAL`] on PID 0x0014.
366    ///
367    /// Interval cites dvb-si/docs/tr_101_211.md §4.4.1/§4.4.2.
368    pub fn upsert_tot(&mut self, sections: Vec<u8>) {
369        self.upsert(well_known::TDT_TOT.value(), sections, TOT_MAX_INTERVAL);
370    }
371
372    /// Emit every entry due at `now` (i.e. `now - last_emit >= interval`, and
373    /// first call always due), packetising via [`SectionPacketiser`], appended
374    /// to `out` (cleared first).
375    ///
376    /// Updates each emitted entry's `last_emit = now`. Deterministic given the
377    /// fed `now` sequence. Returns the packet count appended.
378    pub fn poll_into(&mut self, now: Duration, out: &mut Vec<[u8; TS_PACKET_SIZE]>) -> usize {
379        out.clear();
380        let before = out.len();
381
382        let mut tmp = Vec::new();
383        let packetisers = &mut self.packetisers;
384        for entry in &mut self.entries {
385            let due = match entry.last_emit {
386                None => true,
387                Some(last) => now.saturating_sub(last) >= entry.interval,
388            };
389            if due {
390                let refs = split_sections(&entry.sections);
391                if !refs.is_empty() {
392                    let packetiser = packetiser_for(packetisers, entry.pid);
393                    packetiser.packetise_into(&refs, &mut tmp);
394                    out.append(&mut tmp);
395                }
396                entry.last_emit = Some(now);
397            }
398        }
399
400        out.len() - before
401    }
402
403    /// Allocating convenience wrapper over [`poll_into`](Self::poll_into).
404    pub fn poll(&mut self, now: Duration) -> Vec<[u8; TS_PACKET_SIZE]> {
405        let mut out = Vec::new();
406        self.poll_into(now, &mut out);
407        out
408    }
409}
410
411impl Default for SiMux {
412    fn default() -> Self {
413        Self::new()
414    }
415}
416
417/// Split concatenated complete-section bytes into individual `&[u8]` slices.
418///
419/// Walks the PSI/SI section headers: byte 0 = table_id, bytes 1-2 =
420/// section_length (12 bits). Each slice is 3 + section_length bytes long.
421/// Trailing bytes that don't form a complete section header are discarded.
422fn split_sections(data: &[u8]) -> Vec<&[u8]> {
423    let mut result = Vec::new();
424    let mut pos = 0;
425    while let Some(end) = crate::ts::section_total_len(&data[pos..]).map(|len| pos + len) {
426        if end > data.len() {
427            break;
428        }
429        result.push(&data[pos..end]);
430        pos = end;
431    }
432    result
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::ts::{SectionReassembler, TsPacket};
439    use alloc::vec;
440    use alloc::vec::Vec;
441
442    // ── helpers ──────────────────────────────────────────────────────────────
443
444    /// Build a long-form section with the given table_id and body bytes.
445    /// Returns the full section including its 3-byte header (no CRC — the
446    /// reassembler does not validate CRC).
447    fn build_section(table_id: u8, body_after_length: &[u8]) -> Vec<u8> {
448        let section_length = body_after_length.len() as u16;
449        let mut v = Vec::with_capacity(3 + section_length as usize);
450        v.push(table_id);
451        // SSI=1, PI=0, reserved=11, length upper 4 bits
452        v.push(0xB0 | ((section_length >> 8) as u8 & 0x0F));
453        v.push((section_length & 0xFF) as u8);
454        v.extend_from_slice(body_after_length);
455        v
456    }
457
458    fn concat_sections(sections: &[Vec<u8>]) -> Vec<u8> {
459        let total: usize = sections.iter().map(|s| s.len()).sum();
460        let mut out = Vec::with_capacity(total);
461        for s in sections {
462            out.extend_from_slice(s);
463        }
464        out
465    }
466
467    /// Round-trip `sections` through packetise → reassembler, asserting
468    /// byte-identical output in order and no leftovers.
469    fn assert_round_trip(sections: &[Vec<u8>]) {
470        let mut packetiser = SectionPacketiser::new(0x0100);
471        let refs: Vec<&[u8]> = sections.iter().map(|s| s.as_slice()).collect();
472        let packets = packetiser.packetise(&refs);
473
474        let mut reasm = SectionReassembler::default();
475        for pkt_raw in &packets {
476            let pkt = TsPacket::parse(pkt_raw).expect("parse generated packet");
477            let payload = pkt.payload.expect("payload present");
478            let pusi = pkt.header.pusi;
479            reasm.feed(payload, pusi);
480        }
481
482        let got: Vec<_> = core::iter::from_fn(|| reasm.pop_section()).collect();
483        assert_eq!(
484            got.len(),
485            sections.len(),
486            "section count mismatch: expected {}, got {}",
487            sections.len(),
488            got.len()
489        );
490        for (i, (orig, round)) in sections.iter().zip(got.iter()).enumerate() {
491            assert_eq!(
492                round.as_ref(),
493                orig.as_slice(),
494                "section {i} round-trip mismatch"
495            );
496        }
497        assert!(reasm.is_empty(), "reassembler should be empty after drain");
498    }
499
500    /// 400 sections whose sizes sweep 0..=399 body bytes (so section starts
501    /// land on every offset around the 183/184-byte packet boundaries)
502    /// round-trip byte-identically through packetise -> reassemble (audit
503    /// r01-O1: the section-start lookup is now a forward cursor; this is the
504    /// independent oracle that the rewrite chose the same boundaries).
505    #[test]
506    fn many_sections_with_boundary_straddling_sizes_round_trip() {
507        let sections: Vec<Vec<u8>> = (0..400usize)
508            .map(|n| {
509                let body: Vec<u8> = (0..n).map(|i| (i % 251) as u8).collect();
510                build_section((n % 200) as u8, &body)
511            })
512            .collect();
513        assert_round_trip(&sections);
514    }
515
516    // ── split_sections ──────────────────────────────────────────────────────
517
518    #[test]
519    fn split_single_section() {
520        let s = build_section(0x42, &[0xAA; 5]);
521        let refs = split_sections(&s);
522        assert_eq!(refs.len(), 1);
523        assert_eq!(refs[0], s.as_slice());
524    }
525
526    #[test]
527    fn split_two_sections() {
528        let s1 = build_section(0x42, &[0x01, 0x02]);
529        let s2 = build_section(0x46, &[0x03, 0x04, 0x05]);
530        let both = concat_sections(&[s1.clone(), s2.clone()]);
531        let refs = split_sections(&both);
532        assert_eq!(refs.len(), 2);
533        assert_eq!(refs[0], s1.as_slice());
534        assert_eq!(refs[1], s2.as_slice());
535    }
536
537    #[test]
538    fn split_empty_input() {
539        let refs = split_sections(&[]);
540        assert!(refs.is_empty());
541    }
542
543    #[test]
544    fn split_trailing_garbage_ignored() {
545        let s = build_section(0x42, &[0xAA; 3]);
546        let mut data = s.clone();
547        data.push(0xFF); // trailing byte that doesn't complete a header
548        let refs = split_sections(&data);
549        assert_eq!(refs.len(), 1);
550        assert_eq!(refs[0], s.as_slice());
551    }
552
553    // ── interval constants pinned to spec ────────────────────────────────────
554
555    #[test]
556    fn interval_constants_match_spec() {
557        // TR 101 211 §4.4.1/§4.4.2
558        assert_eq!(NIT_MAX_INTERVAL, Duration::from_secs(10));
559        assert_eq!(BAT_MAX_INTERVAL, Duration::from_secs(10));
560        assert_eq!(SDT_ACTUAL_MAX_INTERVAL, Duration::from_secs(2));
561        assert_eq!(SDT_OTHER_MAX_INTERVAL, Duration::from_secs(10));
562        assert_eq!(EIT_PF_ACTUAL_MAX_INTERVAL, Duration::from_secs(2));
563        assert_eq!(EIT_PF_OTHER_MAX_INTERVAL, Duration::from_secs(10));
564        assert_eq!(EIT_SCHED_MAX_INTERVAL, Duration::from_secs(10));
565        assert_eq!(EIT_SCHED_EXT_MAX_INTERVAL, Duration::from_secs(30));
566        assert_eq!(TDT_MAX_INTERVAL, Duration::from_secs(30));
567        assert_eq!(TOT_MAX_INTERVAL, Duration::from_secs(30));
568
569        // TS 101 154 §4.1.7
570        assert_eq!(PAT_MAX_INTERVAL, Duration::from_millis(100));
571        assert_eq!(PMT_MAX_INTERVAL, Duration::from_millis(100));
572
573        // EN 300 468 §5.1.4.1
574        assert_eq!(MIN_SECTION_INTERVAL, Duration::from_millis(25));
575    }
576
577    // ── SiMux: basic behaviour ───────────────────────────────────────────────
578
579    #[test]
580    fn new_simux_is_empty() {
581        let mut mux = SiMux::new();
582        let pkts = mux.poll(Duration::ZERO);
583        assert!(pkts.is_empty());
584    }
585
586    #[test]
587    fn simux_default_is_empty() {
588        let mut mux = SiMux::default();
589        let pkts = mux.poll(Duration::ZERO);
590        assert!(pkts.is_empty());
591    }
592
593    #[test]
594    fn first_poll_always_emits() {
595        let s = build_section(0x42, &[0xAA; 10]);
596        let mut mux = SiMux::new();
597        mux.upsert(0x0100, s.clone(), Duration::from_millis(100));
598        let pkts = mux.poll(Duration::ZERO);
599        assert!(!pkts.is_empty(), "first poll must emit");
600    }
601
602    #[test]
603    fn first_poll_emits_all_entries() {
604        let s1 = build_section(0x42, &[0x01; 5]);
605        let s2 = build_section(0x46, &[0x02; 5]);
606        let mut mux = SiMux::new();
607        mux.upsert(0x0100, s1.clone(), Duration::from_millis(100));
608        mux.upsert(0x0200, s2.clone(), Duration::from_millis(200));
609        let pkts = mux.poll(Duration::ZERO);
610        // Both entries should emit — we count PID occurrences
611        let pids: Vec<u16> = pkts
612            .iter()
613            .map(|raw| TsPacket::parse(raw).unwrap().header.pid)
614            .collect();
615        assert!(pids.contains(&0x0100));
616        assert!(pids.contains(&0x0200));
617    }
618
619    // ── deterministic schedule ───────────────────────────────────────────────
620
621    #[test]
622    fn entry_emits_only_when_due() {
623        let s = build_section(0x42, &[0xAA; 10]);
624        let mut mux = SiMux::new();
625        mux.upsert(0x0100, s.clone(), Duration::from_millis(100));
626
627        // First poll at t=0: emits
628        let pkts0 = mux.poll(Duration::ZERO);
629        assert!(!pkts0.is_empty());
630
631        // t=50: not yet due
632        let pkts50 = mux.poll(Duration::from_millis(50));
633        assert!(pkts50.is_empty(), "should not emit at t=50");
634
635        // t=99: still not due
636        let pkts99 = mux.poll(Duration::from_millis(99));
637        assert!(pkts99.is_empty(), "should not emit at t=99");
638
639        // t=100: due again
640        let pkts100 = mux.poll(Duration::from_millis(100));
641        assert!(!pkts100.is_empty(), "should emit at t=100");
642    }
643
644    #[test]
645    fn two_entries_different_cadence() {
646        let s1 = build_section(0x42, &[0x01; 5]);
647        let s2 = build_section(0x46, &[0x02; 5]);
648        let mut mux = SiMux::new();
649        mux.upsert(0x0100, concat_sections(&[s1]), Duration::from_millis(100));
650        mux.upsert(0x0200, concat_sections(&[s2]), Duration::from_millis(200));
651
652        // t=0: both emit
653        let pkts = mux.poll(Duration::ZERO);
654        assert!(!pkts.is_empty());
655
656        // t=100: only 0x0100 emits (due at 100)
657        let pkts100 = mux.poll(Duration::from_millis(100));
658        assert!(!pkts100.is_empty());
659        let pids100: Vec<u16> = pkts100
660            .iter()
661            .map(|raw| TsPacket::parse(raw).unwrap().header.pid)
662            .collect();
663        assert!(pids100.contains(&0x0100), "PID 0x0100 should emit at t=100");
664        assert!(
665            !pids100.contains(&0x0200),
666            "PID 0x0200 should NOT emit at t=100"
667        );
668
669        // t=150: neither emits
670        let pkts150 = mux.poll(Duration::from_millis(150));
671        assert!(pkts150.is_empty(), "neither should emit at t=150");
672
673        // t=200: both emit again
674        let pkts200 = mux.poll(Duration::from_millis(200));
675        let pids200: Vec<u16> = pkts200
676            .iter()
677            .map(|raw| TsPacket::parse(raw).unwrap().header.pid)
678            .collect();
679        assert!(pids200.contains(&0x0100), "PID 0x0100 should emit at t=200");
680        assert!(pids200.contains(&0x0200), "PID 0x0200 should emit at t=200");
681    }
682
683    #[test]
684    fn entry_emits_again_after_full_interval() {
685        let s = build_section(0x42, &[0xAA; 20]);
686        let mut mux = SiMux::new();
687        mux.upsert(0x0100, s, Duration::from_secs(1));
688
689        // t=0
690        let n0 = mux.poll_into(Duration::ZERO, &mut Vec::new());
691        assert!(n0 > 0);
692
693        // t=999ms: not due
694        let mut buf = Vec::new();
695        let n999 = mux.poll_into(Duration::from_millis(999), &mut buf);
696        assert_eq!(n999, 0);
697
698        // t=1s: due
699        let mut buf2 = Vec::new();
700        let n1000 = mux.poll_into(Duration::from_millis(1000), &mut buf2);
701        assert!(n1000 > 0);
702    }
703
704    // ── upsert replaces existing entry (same pid + table_id only) ───────────
705
706    #[test]
707    fn upsert_updates_existing_entry() {
708        // Same table_id (0x42, SDT actual) both times: this is a genuine
709        // update of the same logical table, so it must replace, not add.
710        let s1 = build_section(0x42, &[0xAA; 5]);
711        let s2 = build_section(0x42, &[0xBB; 10]);
712        let mut mux = SiMux::new();
713
714        mux.upsert(0x0100, s1, Duration::from_millis(100));
715        mux.upsert(0x0100, s2.clone(), Duration::from_millis(200));
716
717        let pkts = mux.poll(Duration::ZERO);
718
719        // Round-trip the output to verify the updated sections
720        let mut reasm = SectionReassembler::default();
721        for raw in &pkts {
722            let pkt = TsPacket::parse(raw).unwrap();
723            if pkt.header.pid == 0x0100 {
724                reasm.feed(pkt.payload.unwrap(), pkt.header.pusi);
725            }
726        }
727        let got: Vec<_> = core::iter::from_fn(|| reasm.pop_section()).collect();
728        assert_eq!(got.len(), 1);
729        assert_eq!(got[0].as_ref(), s2.as_slice());
730    }
731
732    // ── issue #1000: distinct table_ids sharing a PID must NOT collide ──────
733
734    /// Regression: `upsert_tdt` then `upsert_tot` (both PID 0x0014) used to
735    /// collide because entries were keyed by PID alone — the second call
736    /// silently replaced the first. Both table_ids must now come out, with a
737    /// continuous continuity counter across the shared PID.
738    #[test]
739    fn upsert_tdt_then_tot_both_emitted_with_continuous_cc() {
740        let tdt = build_section(0x70, &[0xAA; 5]); // TDT
741        let tot = build_section(0x73, &[0xBB; 8]); // TOT
742        let mut mux = SiMux::new();
743
744        mux.upsert_tdt(tdt.clone());
745        mux.upsert_tot(tot.clone());
746
747        let pkts = mux.poll(Duration::ZERO);
748        assert!(!pkts.is_empty());
749
750        let mut table_ids_seen = Vec::new();
751        let mut reasm = SectionReassembler::default();
752        let mut prev_cc: Option<u8> = None;
753        for raw in &pkts {
754            let pkt = TsPacket::parse(raw).unwrap();
755            assert_eq!(pkt.header.pid, well_known::TDT_TOT.value());
756            if let Some(prev) = prev_cc {
757                assert_eq!(
758                    pkt.header.continuity_counter,
759                    (prev + 1) & CC_MASK,
760                    "continuity counter must be continuous across table kinds on one PID"
761                );
762            }
763            prev_cc = Some(pkt.header.continuity_counter);
764            reasm.feed(pkt.payload.unwrap(), pkt.header.pusi);
765        }
766        while let Some(section) = reasm.pop_section() {
767            table_ids_seen.push(section.as_ref()[0]);
768        }
769
770        assert!(table_ids_seen.contains(&0x70), "TDT missing from output");
771        assert!(table_ids_seen.contains(&0x73), "TOT missing from output");
772    }
773
774    /// Same collision class for SDT actual (0x42) vs. BAT (0x4A), both on
775    /// PID 0x0011.
776    #[test]
777    fn upsert_sdt_and_bat_both_emitted() {
778        let sdt = build_section(0x42, &[0xCC; 5]);
779        let bat = build_section(0x4A, &[0xDD; 6]);
780        let mut mux = SiMux::new();
781
782        mux.upsert_sdt_actual(sdt.clone());
783        mux.upsert(
784            well_known::SDT_BAT.value(),
785            bat.clone(),
786            Duration::from_millis(1000),
787        );
788
789        let pkts = mux.poll(Duration::ZERO);
790        let mut table_ids_seen = Vec::new();
791        let mut reasm = SectionReassembler::default();
792        for raw in &pkts {
793            let pkt = TsPacket::parse(raw).unwrap();
794            reasm.feed(pkt.payload.unwrap(), pkt.header.pusi);
795        }
796        while let Some(section) = reasm.pop_section() {
797            table_ids_seen.push(section.as_ref()[0]);
798        }
799
800        assert!(table_ids_seen.contains(&0x42), "SDT actual missing");
801        assert!(table_ids_seen.contains(&0x4A), "BAT missing");
802    }
803
804    // ── convenience constructors ─────────────────────────────────────────────
805
806    #[test]
807    fn upsert_pat_uses_correct_pid() {
808        let s = build_section(0x00, &[0x01, 0x02]);
809        let mut mux = SiMux::new();
810        mux.upsert_pat(s);
811        let pkts = mux.poll(Duration::ZERO);
812        let pids: Vec<u16> = pkts
813            .iter()
814            .map(|raw| TsPacket::parse(raw).unwrap().header.pid)
815            .collect();
816        assert!(pids.iter().all(|&p| p == well_known::PAT.value()));
817    }
818
819    #[test]
820    fn upsert_sdt_actual_uses_correct_pid() {
821        let s = build_section(0x42, &[0x01]);
822        let mut mux = SiMux::new();
823        mux.upsert_sdt_actual(s);
824        let pkts = mux.poll(Duration::ZERO);
825        let pids: Vec<u16> = pkts
826            .iter()
827            .map(|raw| TsPacket::parse(raw).unwrap().header.pid)
828            .collect();
829        assert!(pids.iter().all(|&p| p == well_known::SDT_BAT.value()));
830    }
831
832    #[test]
833    fn upsert_nit_uses_correct_pid() {
834        let s = build_section(0x40, &[0x01]);
835        let mut mux = SiMux::new();
836        mux.upsert_nit(s);
837        let pkts = mux.poll(Duration::ZERO);
838        let pids: Vec<u16> = pkts
839            .iter()
840            .map(|raw| TsPacket::parse(raw).unwrap().header.pid)
841            .collect();
842        assert!(pids.iter().all(|&p| p == well_known::NIT.value()));
843    }
844
845    #[test]
846    fn upsert_tdt_uses_correct_pid() {
847        let s = build_section(0x70, &[0x01]);
848        let mut mux = SiMux::new();
849        mux.upsert_tdt(s);
850        let pkts = mux.poll(Duration::ZERO);
851        let pids: Vec<u16> = pkts
852            .iter()
853            .map(|raw| TsPacket::parse(raw).unwrap().header.pid)
854            .collect();
855        assert!(pids.iter().all(|&p| p == well_known::TDT_TOT.value()));
856    }
857
858    #[test]
859    fn upsert_tot_uses_correct_pid() {
860        let s = build_section(0x73, &[0x01]);
861        let mut mux = SiMux::new();
862        mux.upsert_tot(s);
863        let pkts = mux.poll(Duration::ZERO);
864        let pids: Vec<u16> = pkts
865            .iter()
866            .map(|raw| TsPacket::parse(raw).unwrap().header.pid)
867            .collect();
868        assert!(pids.iter().all(|&p| p == well_known::TDT_TOT.value()));
869    }
870
871    // ── round-trip through reassembler ───────────────────────────────────────
872
873    #[test]
874    fn simux_round_trip_single_entry() {
875        let s1 = build_section(0x42, &[0xAA; 20]);
876        let s2 = build_section(0x46, &[0xBB; 15]);
877        let mut mux = SiMux::new();
878        mux.upsert(
879            0x0100,
880            concat_sections(&[s1.clone(), s2.clone()]),
881            Duration::from_millis(100),
882        );
883
884        let pkts = mux.poll(Duration::ZERO);
885
886        let mut reasm = SectionReassembler::default();
887        for raw in &pkts {
888            let pkt = TsPacket::parse(raw).unwrap();
889            if pkt.header.pid == 0x0100 {
890                reasm.feed(pkt.payload.unwrap(), pkt.header.pusi);
891            }
892        }
893        let got: Vec<_> = core::iter::from_fn(|| reasm.pop_section()).collect();
894        assert_eq!(got.len(), 2, "round-trip must recover both sections");
895        assert_eq!(got[0].as_ref(), s1.as_slice());
896        assert_eq!(got[1].as_ref(), s2.as_slice());
897    }
898
899    #[test]
900    fn simux_round_trip_multi_pid() {
901        let s_a = build_section(0x42, &[0xA0; 10]);
902        let s_b = build_section(0x46, &[0xB0; 10]);
903        let mut mux = SiMux::new();
904        mux.upsert(0x0100, s_a.clone(), Duration::from_millis(100));
905        mux.upsert(0x0200, s_b.clone(), Duration::from_millis(100));
906
907        let pkts = mux.poll(Duration::ZERO);
908
909        let mut reasm_a = SectionReassembler::default();
910        let mut reasm_b = SectionReassembler::default();
911        for raw in &pkts {
912            let pkt = TsPacket::parse(raw).unwrap();
913            match pkt.header.pid {
914                0x0100 => reasm_a.feed(pkt.payload.unwrap(), pkt.header.pusi),
915                0x0200 => reasm_b.feed(pkt.payload.unwrap(), pkt.header.pusi),
916                _ => {}
917            }
918        }
919        let got_a: Vec<_> = core::iter::from_fn(|| reasm_a.pop_section()).collect();
920        let got_b: Vec<_> = core::iter::from_fn(|| reasm_b.pop_section()).collect();
921        assert_eq!(got_a.len(), 1);
922        assert_eq!(got_b.len(), 1);
923        assert_eq!(got_a[0].as_ref(), s_a.as_slice());
924        assert_eq!(got_b[0].as_ref(), s_b.as_slice());
925    }
926
927    // ── CC continuity across polls ───────────────────────────────────────────
928
929    #[test]
930    fn continuity_counter_continuous_across_polls() {
931        // Section large enough to span at least 2 packets.
932        let s = build_section(0x42, &[0xAA; 250]);
933        let mut mux = SiMux::new();
934        mux.upsert(0x0100, s, Duration::from_millis(100));
935
936        let pkts1 = mux.poll(Duration::ZERO);
937        assert!(pkts1.len() >= 2, "need ≥2 packets to test CC continuity");
938
939        let last_cc_pkts1 = TsPacket::parse(&pkts1[pkts1.len() - 1])
940            .unwrap()
941            .header
942            .continuity_counter;
943
944        let pkts2 = mux.poll(Duration::from_millis(100));
945        assert!(!pkts2.is_empty(), "second poll must emit");
946
947        let first_cc_pkts2 = TsPacket::parse(&pkts2[0])
948            .unwrap()
949            .header
950            .continuity_counter;
951
952        assert_eq!(
953            first_cc_pkts2,
954            (last_cc_pkts1 + 1) & 0x0F,
955            "CC must continue across poll cycles"
956        );
957    }
958
959    // ── poll_into clears output ──────────────────────────────────────────────
960
961    #[test]
962    fn poll_into_clears_out_before_appending() {
963        let s = build_section(0x42, &[0xAA; 5]);
964        let mut mux = SiMux::new();
965        mux.upsert(0x0100, s, Duration::from_millis(100));
966
967        let mut out = vec![[0u8; TS_PACKET_SIZE]; 42];
968        let n = mux.poll_into(Duration::ZERO, &mut out);
969        assert_eq!(n, out.len(), "out must contain only new packets");
970    }
971
972    // ── debug_assert on sub-25 ms interval ───────────────────────────────────
973
974    #[test]
975    #[cfg(debug_assertions)]
976    #[should_panic(expected = "25 ms")]
977    fn upsert_rejects_sub_25ms_interval_in_debug() {
978        let s = build_section(0x42, &[0xAA; 5]);
979        let mut mux = SiMux::new();
980        mux.upsert(0x0100, s, Duration::from_millis(10));
981    }
982
983    #[test]
984    fn upsert_accepts_25ms_interval() {
985        let s = build_section(0x42, &[0xAA; 5]);
986        let mut mux = SiMux::new();
987        mux.upsert(0x0100, s, MIN_SECTION_INTERVAL);
988        let pkts = mux.poll(Duration::ZERO);
989        assert!(!pkts.is_empty());
990    }
991
992    // ── round-trip property (the mandatory acceptance oracle) ────────────────
993
994    #[test]
995    fn round_trip_single_short_section() {
996        let s = build_section(0x42, &[0xAA; 10]);
997        assert_round_trip(&[s]);
998    }
999
1000    #[test]
1001    fn round_trip_one_byte_body() {
1002        let s = build_section(0x46, &[0xBB]); // 4 bytes total
1003        assert_round_trip(&[s]);
1004    }
1005
1006    #[test]
1007    fn round_trip_section_exactly_pusi_cap_boundary() {
1008        // A section whose total length is exactly PUSI_PAYLOAD_CAP (183).
1009        let body = vec![0xCC; PUSI_PAYLOAD_CAP - 3];
1010        let s = build_section(0x50, &body);
1011        assert_eq!(s.len(), PUSI_PAYLOAD_CAP);
1012        assert_round_trip(&[s]);
1013    }
1014
1015    #[test]
1016    fn round_trip_section_just_over_pusi_cap() {
1017        // One byte more than fits in a PUSI packet → must span to continuation.
1018        let body = vec![0xDD; PUSI_PAYLOAD_CAP - 3 + 1];
1019        let s = build_section(0x52, &body);
1020        assert_eq!(s.len(), PUSI_PAYLOAD_CAP + 1);
1021        assert_round_trip(&[s]);
1022    }
1023
1024    #[test]
1025    fn round_trip_section_spans_many_packets() {
1026        // A 2000-byte section spans ~11 packets.
1027        let body = vec![0xEE; 2000 - 3];
1028        let s = build_section(0x60, &body);
1029        assert_round_trip(&[s]);
1030    }
1031
1032    #[test]
1033    fn round_trip_section_at_max_size() {
1034        // The maximum section (4096 total, the long-form ceiling), whose final
1035        // continuation packet carries the tail followed by 0xFF stuffing. Since
1036        // #148 the reassembler ignores that trailing stuffing instead of
1037        // counting it toward MAX_SECTION_SIZE, so a full-size section round-trips.
1038        let body = vec![0x11; 4096 - 3];
1039        let s = build_section(0x80, &body);
1040        assert_eq!(s.len(), 4096);
1041        assert_round_trip(&[s]);
1042    }
1043
1044    #[test]
1045    fn round_trip_multiple_short_sections_in_one_batch() {
1046        let s1 = build_section(0x42, &[0x01, 0x02]); // 5 bytes
1047        let s2 = build_section(0x46, &[0x03]); // 4 bytes
1048        let s3 = build_section(0x4A, &[0x04, 0x05, 0x06]); // 6 bytes
1049        assert_round_trip(&[s1, s2, s3]);
1050    }
1051
1052    #[test]
1053    fn round_trip_section_ends_exactly_at_boundary() {
1054        // First section is exactly PUSI_PAYLOAD_CAP bytes — ends at packet
1055        // boundary.  Second section starts fresh in the next packet with
1056        // PUSI=1, pointer_field=0.
1057        let body1 = vec![0xA1; PUSI_PAYLOAD_CAP - 3];
1058        let s1 = build_section(0x50, &body1);
1059        assert_eq!(s1.len(), PUSI_PAYLOAD_CAP);
1060
1061        let s2 = build_section(0x52, &[0xB1, 0xB2]);
1062        assert_round_trip(&[s1, s2]);
1063    }
1064
1065    #[test]
1066    fn continuation_packet_never_overshoots_next_section_start() {
1067        // First section is 366 bytes total: it fills the first (PUSI) packet's
1068        // 183-byte payload exactly, then the *second* packet (a continuation,
1069        // starting at pos=183) is exactly 183 bytes short of the next
1070        // section's start (183 + 183 = 366) — the mid-stream analogue of
1071        // `round_trip_section_ends_exactly_at_boundary` above, which only
1072        // exercises the boundary at pos=0.
1073        let body1 = vec![0xA5; 366 - 3];
1074        let s1 = build_section(0x50, &body1);
1075        assert_eq!(s1.len(), 366);
1076        let s2 = build_section(0x52, &[0xB1, 0xB2, 0xB3]);
1077
1078        let mut packetiser = SectionPacketiser::new(0x0100);
1079        let packets = packetiser.packetise(&[s1.as_slice(), s2.as_slice()]);
1080
1081        for pkt_raw in &packets {
1082            let pkt = TsPacket::parse(pkt_raw).expect("parse generated packet");
1083            let payload = pkt.payload.expect("payload present");
1084            if pkt.header.pusi {
1085                let pointer = payload[0] as usize;
1086                // W1 (#1074): pre-fix this was 183 — a pointer_field pointing
1087                // past the packet's own payload, so no section byte actually
1088                // starts within it.
1089                assert!(
1090                    pointer < PUSI_PAYLOAD_CAP,
1091                    "pointer_field {pointer} leaves no room for a section to \
1092                     start within this packet (H.222.0 §2.4.4)"
1093                );
1094            }
1095        }
1096
1097        assert_round_trip(&[s1, s2]);
1098    }
1099
1100    #[test]
1101    fn round_trip_mix_small_large_sections() {
1102        // Mix of small and spanning sections that stress pointer_field and
1103        // concatenation.
1104        let s1 = build_section(0x10, &[0xAA; 5]);
1105        let body2 = vec![0xBB; 200];
1106        let s2 = build_section(0x20, &body2);
1107        let s3 = build_section(0x30, &[0xCC; 50]);
1108        let body4 = vec![0xDD; 800];
1109        let s4 = build_section(0x40, &body4);
1110        let s5 = build_section(0x50, &[0xEE]); // 1-byte body
1111        assert_round_trip(&[s1, s2, s3, s4, s5]);
1112    }
1113
1114    // ── continuity counter ───────────────────────────────────────────────────
1115
1116    #[test]
1117    fn continuity_counter_increments_per_packet() {
1118        // Use a section large enough to span several packets.
1119        let body = vec![0xAA; 500];
1120        let section = build_section(0x42, &body);
1121        let mut p = SectionPacketiser::new(0x0100);
1122
1123        let packets = p.packetise(&[&section]);
1124        assert!(packets.len() >= 3, "need multiple packets to test CC");
1125
1126        let mut last_cc: Option<u8> = None;
1127        for pkt_raw in &packets {
1128            let pkt = TsPacket::parse(pkt_raw).unwrap();
1129            let cc = pkt.header.continuity_counter;
1130            if let Some(last) = last_cc {
1131                assert_eq!(cc, (last + 1) & 0x0F, "CC must increment per packet");
1132            }
1133            last_cc = Some(cc);
1134        }
1135    }
1136
1137    #[test]
1138    fn continuity_counter_wraps_and_continues_across_calls() {
1139        let mut p = SectionPacketiser::with_continuity(0x0100, 14);
1140        // Section large enough to span at least 3 packets.
1141        let body = vec![0xBB; 500];
1142        let s = build_section(0x42, &body);
1143
1144        // First call: CC 14, 15, 0, …
1145        let pkts1 = p.packetise(&[&s]);
1146        assert!(pkts1.len() >= 3, "section must span ≥3 packets");
1147        let ccs1: Vec<u8> = pkts1
1148            .iter()
1149            .map(|b| TsPacket::parse(b).unwrap().header.continuity_counter)
1150            .collect();
1151        assert_eq!(ccs1[0], 14);
1152        assert_eq!(ccs1[1], 15);
1153        assert_eq!(ccs1[2], 0);
1154
1155        // Second call: CC continues from where first left off.
1156        let pkts2 = p.packetise(&[&s]);
1157        let cc_first_pkt2 = TsPacket::parse(&pkts2[0])
1158            .unwrap()
1159            .header
1160            .continuity_counter;
1161        assert_eq!(cc_first_pkt2, ccs1.last().map(|c| (c + 1) & 0x0F).unwrap());
1162    }
1163
1164    // ── PUSI placement ──────────────────────────────────────────────────────
1165
1166    #[test]
1167    fn pusi_set_when_section_starts() {
1168        let s = build_section(0x42, &[0xAA; 10]);
1169        let mut p = SectionPacketiser::new(0x0100);
1170        let packets = p.packetise(&[&s]);
1171        assert!(!packets.is_empty());
1172        let pkt = TsPacket::parse(&packets[0]).unwrap();
1173        assert!(pkt.header.pusi, "first packet must have PUSI=1");
1174    }
1175
1176    #[test]
1177    fn pusi_not_set_on_mid_section_continuation() {
1178        let body = vec![0xAA; 500];
1179        let s = build_section(0x42, &body);
1180        let mut p = SectionPacketiser::new(0x0100);
1181        let packets = p.packetise(&[&s]);
1182        assert!(packets.len() >= 2);
1183        let pkt1 = TsPacket::parse(&packets[0]).unwrap();
1184        let pkt2 = TsPacket::parse(&packets[1]).unwrap();
1185        assert!(pkt1.header.pusi, "first packet must have PUSI=1");
1186        assert!(
1187            !pkt2.header.pusi,
1188            "second packet is continuation, must have PUSI=0"
1189        );
1190    }
1191
1192    #[test]
1193    fn pointer_field_equals_tail_length_before_new_section() {
1194        // Section1 = 200 bytes.  Section2 = 50 bytes.
1195        // Packet 1: PUSI=1, pointer=0, section1 head.
1196        // Packet 2: PUSI=1, pointer > 0 (tail of section1 before section2).
1197        let body1 = vec![0xA1; 197]; // 200-byte section
1198        let s1 = build_section(0x52, &body1);
1199        assert_eq!(s1.len(), 200);
1200        let s2 = build_section(0x54, &[0xB1; 47]); // 50-byte section
1201        assert_eq!(s2.len(), 50);
1202
1203        let mut p = SectionPacketiser::new(0x0100);
1204        let packets = p.packetise(&[&s1, &s2]);
1205
1206        // Find the packet where PUSI=1 and pointer>0.
1207        let pkt_with_pointer = packets
1208            .iter()
1209            .map(|raw| TsPacket::parse(raw).unwrap())
1210            .find(|pkt| pkt.header.pusi && pkt.payload.is_some_and(|pl| pl.first() != Some(&0)))
1211            .expect("must have a PUSI packet with non-zero pointer");
1212
1213        let payload = pkt_with_pointer.payload.unwrap();
1214        let pointer = payload[0] as usize;
1215        assert!(pointer > 0, "pointer must be non-zero");
1216        // The tail bytes should be from the end of section1.
1217        let tail_start = s1.len() - pointer;
1218        assert_eq!(&payload[1..1 + pointer], &s1[tail_start..]);
1219    }
1220
1221    // ── stuffing ─────────────────────────────────────────────────────────────
1222
1223    #[test]
1224    fn final_packet_unused_tail_is_stuffing() {
1225        let s = build_section(0x42, &[0xAA; 5]); // 8 bytes total
1226        let mut p = SectionPacketiser::new(0x0100);
1227        let packets = p.packetise(&[&s]);
1228
1229        let pkt = TsPacket::parse(&packets[0]).unwrap();
1230        let payload = pkt.payload.unwrap();
1231        assert_eq!(payload[0], 0, "pointer_field should be 0");
1232
1233        let section_end = 1 + s.len(); // after pointer + section
1234        assert!(
1235            section_end < payload.len(),
1236            "must have stuffing after section"
1237        );
1238        for &b in &payload[section_end..] {
1239            assert_eq!(b, STUFFING_BYTE, "all trailing bytes must be 0xFF");
1240        }
1241    }
1242
1243    #[test]
1244    fn reassembler_discards_stuffing() {
1245        let s1 = build_section(0x42, &[0xAA; 10]);
1246        let s2 = build_section(0x46, &[0xBB; 5]);
1247
1248        let mut p = SectionPacketiser::new(0x0100);
1249        let packets = p.packetise(&[&s1, &s2]);
1250
1251        let mut reasm = SectionReassembler::default();
1252        for pkt_raw in &packets {
1253            let pkt = TsPacket::parse(pkt_raw).unwrap();
1254            reasm.feed(pkt.payload.unwrap(), pkt.header.pusi);
1255        }
1256
1257        let got: Vec<_> = core::iter::from_fn(|| reasm.pop_section()).collect();
1258        assert_eq!(got.len(), 2);
1259        assert!(
1260            reasm.is_empty(),
1261            "stuffing tail must be discarded, not buffered"
1262        );
1263    }
1264
1265    // ── misc ─────────────────────────────────────────────────────────────────
1266
1267    #[test]
1268    fn empty_batch_produces_no_packets() {
1269        let mut p = SectionPacketiser::new(0x0100);
1270        let packets: Vec<[u8; TS_PACKET_SIZE]> = p.packetise(&[]);
1271        assert!(packets.is_empty());
1272    }
1273
1274    #[test]
1275    fn packetise_into_clears_out_first() {
1276        let s = build_section(0x42, &[0xAA; 5]);
1277        let mut p = SectionPacketiser::new(0x0100);
1278
1279        let mut out = vec![[0u8; TS_PACKET_SIZE]; 99]; // pre-existing junk
1280        let n = p.packetise_into(&[&s], &mut out);
1281        assert_eq!(n, out.len(), "out must contain only the new packets");
1282        // Verify the output is correct (round-trip).
1283        let mut reasm = SectionReassembler::default();
1284        for pkt_raw in &out {
1285            let pkt = TsPacket::parse(pkt_raw).unwrap();
1286            reasm.feed(pkt.payload.unwrap(), pkt.header.pusi);
1287        }
1288        let got = reasm.pop_section().unwrap();
1289        assert_eq!(got.as_ref(), s.as_slice());
1290    }
1291
1292    #[test]
1293    fn pid_is_correct() {
1294        let p = SectionPacketiser::new(0x1234);
1295        assert_eq!(p.pid(), 0x1234);
1296    }
1297
1298    #[test]
1299    fn with_continuity_masks_to_4_bits() {
1300        let p = SectionPacketiser::with_continuity(0x0100, 0xFE);
1301        assert_eq!(p.continuity_counter(), 0x0E);
1302    }
1303
1304    #[test]
1305    fn has_payload_always_true_no_adaptation() {
1306        let s = build_section(0x42, &[0xAA; 50]);
1307        let mut p = SectionPacketiser::new(0x0100);
1308        let packets = p.packetise(&[&s]);
1309        for pkt_raw in &packets {
1310            let pkt = TsPacket::parse(pkt_raw).unwrap();
1311            assert!(pkt.header.has_payload, "every packet must carry payload");
1312            assert!(!pkt.header.has_adaptation, "no adaptation field is emitted");
1313            assert!(!pkt.header.tei, "TEI must be false");
1314            assert_eq!(pkt.header.scrambling, 0, "scrambling must be 0");
1315        }
1316    }
1317}