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