Skip to main content

ebustl_parser/
validate.rs

1//! Validation of the rules in Tech 3264 that relate several fields or blocks.
2
3use std::collections::{BTreeSet, HashMap};
4use std::fmt;
5
6use crate::text_field::TF_UNUSED_SPACE;
7use crate::{DisplayStandardCode, InvalidFieldValue, Stl, Subtitle, TextElement, Time};
8
9/// A deviation from the spec, found by [`Stl::validate`].
10///
11/// # Example
12///
13/// ```rust
14/// let stl = ebustl_parser::parse_stl_from_file("stls/test.stl").unwrap();
15/// for issue in stl.validate() {
16///     println!("{issue}");
17/// }
18/// ```
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ValidationIssue {
21    tti_index: Option<usize>,
22    field: &'static str,
23    msg: String,
24}
25
26impl ValidationIssue {
27    fn gsi(field: &'static str, msg: String) -> Self {
28        Self {
29            tti_index: None,
30            field,
31            msg,
32        }
33    }
34
35    fn tti(tti_index: usize, field: &'static str, msg: String) -> Self {
36        Self {
37            tti_index: Some(tti_index),
38            field,
39            msg,
40        }
41    }
42
43    /// The index in [`Stl::ttis`] of the TTI block with the issue, or `None` for the GSI block.
44    pub fn tti_index(&self) -> Option<usize> {
45        self.tti_index
46    }
47
48    /// Mnemonic of the field in the spec, e.g. "TNB".
49    pub fn field(&self) -> &'static str {
50        self.field
51    }
52
53    /// What deviates from the spec.
54    pub fn msg(&self) -> &str {
55        &self.msg
56    }
57}
58
59impl fmt::Display for ValidationIssue {
60    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
61        match self.tti_index {
62            None => write!(f, "GSI block, {}: {}", self.field, self.msg),
63            Some(index) => write!(f, "TTI block {index}, {}: {}", self.field, self.msg),
64        }
65    }
66}
67
68impl Stl {
69    /// Checks the subtitle list against the rules in the spec, and returns the deviations
70    /// found. An empty list means that no deviation was found.
71    ///
72    /// The fields are checked one at a time as by the validating accessors, e.g.
73    /// [`GsiBlock::creation_date`](crate::GsiBlock::creation_date), and together:
74    ///
75    /// - TND is 1-9 and DSN 1 up to TND.
76    /// - TNB, TNS and TNG match the TTI blocks, when the subtitle list is on a single disk.
77    /// - TCF is the TCI of the first subtitle.
78    /// - Subtitle Numbers are continuous and ascending, and TCI ascending within a subtitle group.
79    /// - Extension blocks have ascending EBN, the last block of a subtitle has EBN FFh and its
80    ///   Text Field ends with unused space (8Fh). EBN F0h-FDh are reserved.
81    /// - TCI, TCO, JC and CF of the first block of each subtitle are valid, and TCO is not
82    ///   before TCI.
83    /// - VP is 1-23 for teletext and at most MNR for open subtitles.
84    /// - The Text Field only has control codes for the Display Standard Code, and no reserved
85    ///   codes.
86    ///
87    /// Time codes are expected to increase with time, as the spec requires ascending TCI
88    /// (section 4.3.1). A subtitle list that passes midnight, where time codes start over from
89    /// 00:00:00:00, or that has cuts in the time codes is therefore reported. Comment subtitles
90    /// (CF 01h) are not included in the time code order and TCF.
91    ///
92    /// # Example
93    ///
94    /// ```rust
95    /// use ebustl_parser::{Stl, Time, TtiFormat};
96    ///
97    /// let time = |seconds| Time { hours: 10, minutes: 0, seconds, frames: 0 };
98    /// let format = |vp| TtiFormat { jc: 2, vp, dh: false };
99    /// let mut stl = Stl::new();
100    /// stl.add_sub(time(1), time(3), "Valid", format(20)).unwrap();
101    /// assert!(stl.validate().is_empty());
102    ///
103    /// // Teletext has rows 1-23
104    /// stl.add_sub(time(4), time(6), "Below the screen", format(30)).unwrap();
105    /// let issues = stl.validate();
106    /// assert_eq!(1, issues.len());
107    /// assert_eq!("VP", issues[0].field());
108    /// assert_eq!(Some(1), issues[0].tti_index());
109    /// ```
110    pub fn validate(&self) -> Vec<ValidationIssue> {
111        let mut issues = Vec::new();
112        self.check_gsi_fields(&mut issues);
113        self.check_disks(&mut issues);
114        self.check_totals(&mut issues);
115        self.check_first_in_cue(&mut issues);
116        self.check_subtitles(&mut issues);
117        issues
118    }
119
120    fn check_gsi_fields(&self, issues: &mut Vec<ValidationIssue>) {
121        let gsi = &self.gsi;
122        push_error(issues, None, gsi.language());
123        push_error(issues, None, gsi.creation_date());
124        push_error(issues, None, gsi.revision_date());
125        push_error(issues, None, gsi.revision_number());
126        push_error(issues, None, gsi.time_code_start_of_programme());
127        push_error(issues, None, gsi.time_code_first_in_cue());
128        push_error(issues, None, gsi.country_of_origin());
129    }
130
131    fn check_disks(&self, issues: &mut Vec<ValidationIssue>) {
132        let (tnd, dsn) = (self.gsi.tnd, self.gsi.dsn);
133        if !(1..=9).contains(&tnd) {
134            issues.push(ValidationIssue::gsi("TND", format!("{tnd} is not 1-9")));
135        }
136        if dsn == 0 || dsn > tnd {
137            issues.push(ValidationIssue::gsi("DSN", format!("{dsn} is not 1-{tnd}")));
138        }
139    }
140
141    /// TNB, TNS and TNG count the whole subtitle list, which may span several disks.
142    fn check_totals(&self, issues: &mut Vec<ValidationIssue>) {
143        if self.gsi.tnd != 1 {
144            return;
145        }
146        let blocks = self.ttis.len();
147        if usize::try_from(self.gsi.tnb) != Ok(blocks) {
148            let msg = format!("{} but there are {blocks} TTI blocks", self.gsi.tnb);
149            issues.push(ValidationIssue::gsi("TNB", msg));
150        }
151        let subtitles: Vec<Subtitle> = self.subtitles().filter(has_text).collect();
152        if usize::try_from(self.gsi.tns) != Ok(subtitles.len()) {
153            let msg = format!(
154                "{} but there are {} subtitles",
155                self.gsi.tns,
156                subtitles.len()
157            );
158            issues.push(ValidationIssue::gsi("TNS", msg));
159        }
160        let groups: BTreeSet<u8> = subtitles
161            .iter()
162            .map(|subtitle| subtitle.header_block().sgn)
163            .collect();
164        if usize::from(self.gsi.tng) != groups.len() {
165            let msg = format!(
166                "{} but there are {} subtitle groups",
167                self.gsi.tng,
168                groups.len()
169            );
170            issues.push(ValidationIssue::gsi("TNG", msg));
171        }
172    }
173
174    fn check_first_in_cue(&self, issues: &mut Vec<ValidationIssue>) {
175        let Ok(Some(tcf)) = self.gsi.time_code_first_in_cue() else {
176            return;
177        };
178        let first = self
179            .subtitles()
180            .find(|subtitle| has_text(subtitle) && !subtitle.is_comment());
181        if let Some(first) = first {
182            let tci = first.header_block().tci;
183            if tci != tcf {
184                let msg = format!(
185                    "{} is not the TCI {} of the first subtitle",
186                    tcf.hhmmssff(),
187                    tci.hhmmssff()
188                );
189                issues.push(ValidationIssue::gsi("TCF", msg));
190            }
191        }
192    }
193
194    fn check_subtitles(&self, issues: &mut Vec<ValidationIssue>) {
195        let mut first_index = 0;
196        let mut previous_sn = None;
197        // The latest Time Code In of each subtitle group
198        let mut group_tci: HashMap<u8, Time> = HashMap::new();
199        for subtitle in self.subtitles() {
200            let sn = subtitle.subtitle_number();
201            if let Some(previous) = previous_sn
202                && u32::from(sn) != u32::from(previous) + 1
203            {
204                let msg = format!("{sn} does not follow {previous}");
205                issues.push(ValidationIssue::tti(first_index, "SN", msg));
206            }
207            previous_sn = Some(sn);
208
209            self.check_blocks(&subtitle, first_index, issues);
210            if has_text(&subtitle) {
211                let header_index = first_index + header_offset(&subtitle);
212                self.check_header(&subtitle, header_index, issues);
213                self.check_text_field(&subtitle, header_index, issues);
214                if !subtitle.is_comment() {
215                    check_tci_order(&subtitle, header_index, &mut group_tci, issues);
216                }
217            }
218            first_index += subtitle.blocks().len();
219        }
220    }
221
222    /// Extension Block Numbers and the terminating unused space of a subtitle.
223    fn check_blocks(
224        &self,
225        subtitle: &Subtitle,
226        first_index: usize,
227        issues: &mut Vec<ValidationIssue>,
228    ) {
229        let mut previous_extension = None;
230        let mut last_text_block = None;
231        for (index, block) in (first_index..).zip(subtitle.blocks()) {
232            let ebn = block.ebn;
233            if (0xF0..=0xFD).contains(&ebn) {
234                let msg = format!("{ebn:02X}h is reserved for future use");
235                issues.push(ValidationIssue::tti(index, "EBN", msg));
236            } else if !block.is_text_block() {
237                continue;
238            } else if !block.is_last_block() {
239                if previous_extension.is_some_and(|previous| ebn <= previous) {
240                    let msg = format!("{ebn:02X}h is not in ascending order");
241                    issues.push(ValidationIssue::tti(index, "EBN", msg));
242                }
243                previous_extension = Some(ebn);
244            }
245            if block.is_text_block() {
246                last_text_block = Some((index, block));
247            }
248        }
249        let Some((index, block)) = last_text_block else {
250            return;
251        };
252        if !block.is_last_block() {
253            let msg = format!("{:02X}h, the last block of a subtitle has FFh", block.ebn);
254            issues.push(ValidationIssue::tti(index, "EBN", msg));
255        }
256        if block.tf.last() != Some(&TF_UNUSED_SPACE) {
257            let msg = "the last block of a subtitle does not end with unused space (8Fh)";
258            issues.push(ValidationIssue::tti(index, "TF", msg.to_string()));
259        }
260    }
261
262    /// The fields in bytes 4-15, which only the first block of a subtitle carries.
263    fn check_header(&self, subtitle: &Subtitle, index: usize, issues: &mut Vec<ValidationIssue>) {
264        let header = subtitle.header_block();
265        let dfc = &self.gsi.dfc;
266        push_error(issues, Some(index), header.justification());
267        push_error(issues, Some(index), header.comment_flag());
268        let tci = header.time_code_in(dfc);
269        let tco = header.time_code_out(dfc);
270        if let (Ok(tci), Ok(tco)) = (&tci, &tco)
271            && tco < tci
272        {
273            let msg = format!("{} is before TCI {}", tco.hhmmssff(), tci.hhmmssff());
274            issues.push(ValidationIssue::tti(index, "TCO", msg));
275        }
276        push_error(issues, Some(index), tci);
277        push_error(issues, Some(index), tco);
278
279        let vp = header.vp;
280        match self.gsi.dsc {
281            DisplayStandardCode::Level1Teletext | DisplayStandardCode::Level2Teletext
282                if !(1..=23).contains(&vp) =>
283            {
284                let msg = format!("{vp} is not a teletext row 1-23");
285                issues.push(ValidationIssue::tti(index, "VP", msg));
286            }
287            DisplayStandardCode::OpenSubtitling if u16::from(vp) > self.gsi.mnr => {
288                let msg = format!("{vp} is above MNR {}", self.gsi.mnr);
289                issues.push(ValidationIssue::tti(index, "VP", msg));
290            }
291            _ => {}
292        }
293    }
294
295    /// Control codes that do not belong to the Display Standard Code, and reserved codes.
296    fn check_text_field(
297        &self,
298        subtitle: &Subtitle,
299        index: usize,
300        issues: &mut Vec<ValidationIssue>,
301    ) {
302        let elements = subtitle.text_elements();
303        let dsc = &self.gsi.dsc;
304        let open = *dsc == DisplayStandardCode::OpenSubtitling;
305        let teletext = matches!(
306            dsc,
307            DisplayStandardCode::Level1Teletext | DisplayStandardCode::Level2Teletext
308        );
309        if open
310            && elements
311                .iter()
312                .any(|e| matches!(e, TextElement::Teletext(_)))
313        {
314            let msg = "teletext control codes in an open subtitle".to_string();
315            issues.push(ValidationIssue::tti(index, "TF", msg));
316        }
317        if teletext && elements.iter().any(|e| matches!(e, TextElement::Open(_))) {
318            let msg = "open subtitle control codes in a teletext subtitle".to_string();
319            issues.push(ValidationIssue::tti(index, "TF", msg));
320        }
321        for element in &elements {
322            if let TextElement::Reserved(code) = element {
323                let msg = format!("{code:02X}h is reserved for future use");
324                issues.push(ValidationIssue::tti(index, "TF", msg));
325            }
326        }
327    }
328}
329
330/// Time Code In must not go back within a subtitle group (section 4.3.1).
331fn check_tci_order(
332    subtitle: &Subtitle,
333    index: usize,
334    group_tci: &mut HashMap<u8, Time>,
335    issues: &mut Vec<ValidationIssue>,
336) {
337    let header = subtitle.header_block();
338    if let Some(previous) = group_tci.get(&header.sgn)
339        && header.tci < *previous
340    {
341        let msg = format!(
342            "{} is before {} of the previous subtitle in the group",
343            header.tci.hhmmssff(),
344            previous.hhmmssff()
345        );
346        issues.push(ValidationIssue::tti(index, "TCI", msg));
347    }
348    group_tci.insert(header.sgn, header.tci);
349}
350
351fn push_error<T, V>(
352    issues: &mut Vec<ValidationIssue>,
353    tti_index: Option<usize>,
354    result: Result<T, InvalidFieldValue<V>>,
355) {
356    if let Err(err) = result {
357        issues.push(ValidationIssue {
358            tti_index,
359            field: err.field(),
360            msg: err.msg().to_string(),
361        });
362    }
363}
364
365/// Returns `true` unless the subtitle only has user data or reserved blocks.
366fn has_text(subtitle: &Subtitle) -> bool {
367    subtitle.text_blocks().next().is_some()
368}
369
370/// Position of the header block within the blocks of the subtitle.
371fn header_offset(subtitle: &Subtitle) -> usize {
372    let header = subtitle.header_block();
373    subtitle
374        .blocks()
375        .iter()
376        .position(|block| std::ptr::eq(block, header))
377        .unwrap_or(0)
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::{CumulativeStatus, TtiBlock, TtiFormat};
384
385    fn time(minutes: u8, seconds: u8) -> Time {
386        Time {
387            hours: 10,
388            minutes,
389            seconds,
390            frames: 0,
391        }
392    }
393
394    /// A valid teletext subtitle list with `count` subtitles, two seconds each
395    fn stl(count: u8) -> Stl {
396        let mut stl = Stl::new();
397        for i in 0..count {
398            let format = TtiFormat {
399                jc: 2,
400                vp: 20,
401                dh: true,
402            };
403            let text = format!("Subtitle {i}\nSecond row");
404            stl.add_sub(time(0, 2 * i), time(0, 2 * i + 1), &text, format)
405                .unwrap();
406        }
407        stl
408    }
409
410    /// The (TTI index, field) of each issue
411    fn issues(stl: &Stl) -> Vec<(Option<usize>, &'static str)> {
412        stl.validate()
413            .iter()
414            .map(|issue| (issue.tti_index(), issue.field()))
415            .collect()
416    }
417
418    fn user_data_block(sn: u16) -> TtiBlock {
419        let mut block = TtiBlock::new(
420            sn,
421            time(0, 0),
422            time(0, 0),
423            "user data",
424            TtiFormat {
425                jc: 0,
426                vp: 0,
427                dh: false,
428            },
429            crate::CharacterCodeTable::Latin,
430        );
431        block.ebn = 0xFE;
432        block
433    }
434
435    #[test]
436    fn written_subtitle_list_is_valid() {
437        let mut stl = stl(3);
438        let long_text = "A subtitle long enough to need extension blocks. ".repeat(5);
439        let format = TtiFormat {
440            jc: 1,
441            vp: 1,
442            dh: false,
443        };
444        stl.add_sub(time(1, 0), time(1, 5), &long_text, format)
445            .unwrap();
446
447        assert!(stl.ttis.len() > 4, "extension blocks expected");
448        assert_eq!(Vec::<ValidationIssue>::new(), stl.validate());
449    }
450
451    #[test]
452    fn test_file_is_valid() {
453        let stl = crate::parse_stl_from_file("stls/test.stl").unwrap();
454        assert_eq!(Vec::<ValidationIssue>::new(), stl.validate());
455    }
456
457    #[test]
458    fn invalid_gsi_field() {
459        let mut stl = stl(1);
460        stl.gsi.cd = "911341".to_string();
461        let issues = stl.validate();
462        assert_eq!(1, issues.len());
463        assert_eq!(
464            "GSI block, CD: \"911341\" is not an existing date",
465            issues[0].to_string()
466        );
467    }
468
469    #[test]
470    fn disk_numbers() {
471        let mut stl = stl(1);
472        stl.gsi.tnd = 0;
473        stl.gsi.dsn = 0;
474        assert_eq!(vec![(None, "TND"), (None, "DSN")], issues(&stl));
475
476        stl.gsi.tnd = 2;
477        stl.gsi.dsn = 3;
478        assert_eq!(vec![(None, "DSN")], issues(&stl));
479    }
480
481    #[test]
482    fn totals() {
483        let mut stl = stl(2);
484        stl.gsi.tnb = 3;
485        stl.gsi.tns = 1;
486        stl.gsi.tng = 2;
487        assert_eq!(
488            vec![(None, "TNB"), (None, "TNS"), (None, "TNG")],
489            issues(&stl)
490        );
491    }
492
493    #[test]
494    fn totals_are_not_checked_on_several_disks() {
495        let mut stl = stl(2);
496        stl.gsi.tnb = 1;
497        stl.gsi.tnd = 2;
498        assert!(stl.validate().is_empty());
499    }
500
501    #[test]
502    fn totals_do_not_count_user_data_only_subtitles() {
503        let mut stl = stl(1);
504        stl.ttis.push(user_data_block(2));
505        stl.gsi.tnb = 2;
506        assert!(stl.validate().is_empty());
507    }
508
509    #[test]
510    fn subtitle_groups_are_counted() {
511        let mut stl = stl(2);
512        stl.ttis[1].sgn = 1;
513        assert_eq!(vec![(None, "TNG")], issues(&stl));
514        stl.gsi.tng = 2;
515        assert!(stl.validate().is_empty());
516    }
517
518    #[test]
519    fn first_in_cue() {
520        let mut stl = stl(2);
521        stl.gsi.tcf = "10000100".to_string();
522        let issues = stl.validate();
523        assert_eq!(
524            "GSI block, TCF: 10:00:01:00 is not the TCI 10:00:00:00 of the first subtitle",
525            issues[0].to_string()
526        );
527    }
528
529    #[test]
530    fn first_in_cue_skips_comments() {
531        let mut stl = stl(2);
532        stl.ttis[0].cf = 1;
533        stl.gsi.tcf = "10000200".to_string();
534        assert!(stl.validate().is_empty());
535    }
536
537    #[test]
538    fn subtitle_numbers_are_continuous() {
539        let mut stl = stl(3);
540        stl.ttis[2].sn = 5;
541        assert_eq!(vec![(Some(2), "SN")], issues(&stl));
542    }
543
544    #[test]
545    fn time_code_in_is_ascending_within_group() {
546        let mut stl = stl(3);
547        stl.ttis[2].tci = time(0, 1);
548        stl.ttis[2].tco = time(0, 2);
549        assert_eq!(vec![(Some(2), "TCI")], issues(&stl));
550
551        // Another subtitle group may start over
552        stl.ttis[2].sgn = 1;
553        stl.gsi.tng = 2;
554        assert!(stl.validate().is_empty());
555    }
556
557    #[test]
558    fn time_codes_passing_midnight_are_reported() {
559        let mut stl = stl(2);
560        let late = |seconds| Time {
561            hours: 23,
562            minutes: 59,
563            seconds,
564            frames: 0,
565        };
566        let early = |seconds| Time {
567            hours: 0,
568            minutes: 0,
569            seconds,
570            frames: 0,
571        };
572        stl.ttis[0].tci = late(58);
573        stl.ttis[0].tco = early(1);
574        stl.ttis[1].tci = early(2);
575        stl.ttis[1].tco = early(3);
576        stl.gsi.tcf = "23595800".to_string();
577        assert_eq!(vec![(Some(0), "TCO"), (Some(1), "TCI")], issues(&stl));
578    }
579
580    #[test]
581    fn equal_time_codes_are_allowed() {
582        let mut stl = stl(2);
583        stl.ttis[0].tco = stl.ttis[0].tci;
584        stl.ttis[1].tci = stl.ttis[0].tci;
585        stl.ttis[1].tco = stl.ttis[0].tci;
586        assert!(stl.validate().is_empty());
587    }
588
589    #[test]
590    fn time_code_out_before_in() {
591        let mut stl = stl(1);
592        stl.ttis[0].tco = Time {
593            hours: 9,
594            ..time(0, 0)
595        };
596        let issues = stl.validate();
597        assert_eq!(
598            "TTI block 0, TCO: 09:00:00:00 is before TCI 10:00:00:00",
599            issues[0].to_string()
600        );
601    }
602
603    #[test]
604    fn invalid_header_fields() {
605        let mut stl = stl(1);
606        stl.ttis[0].jc = 4;
607        stl.ttis[0].cf = 2;
608        stl.ttis[0].tci.frames = 25;
609        // TCF no longer matches the changed TCI
610        assert_eq!(
611            vec![
612                (None, "TCF"),
613                (Some(0), "JC"),
614                (Some(0), "CF"),
615                (Some(0), "TCI")
616            ],
617            issues(&stl)
618        );
619    }
620
621    #[test]
622    fn only_the_first_block_carries_header_fields() {
623        let mut stl = Stl::new();
624        let format = TtiFormat {
625            jc: 2,
626            vp: 20,
627            dh: false,
628        };
629        stl.add_sub(time(0, 0), time(0, 1), &"x".repeat(200), format)
630            .unwrap();
631        // Values in extension blocks have no meaning
632        stl.ttis[1].jc = 0x20;
633        stl.ttis[1].tci.frames = 99;
634        stl.ttis[1].cs = CumulativeStatus::LastInSet;
635        assert!(stl.validate().is_empty());
636    }
637
638    #[test]
639    fn extension_block_numbers() {
640        let mut stl = Stl::new();
641        let format = TtiFormat {
642            jc: 2,
643            vp: 20,
644            dh: false,
645        };
646        stl.add_sub(time(0, 0), time(0, 1), &"x".repeat(300), format)
647            .unwrap();
648        assert_eq!(
649            vec![0x00, 0x01, 0xFF],
650            stl.ttis.iter().map(|b| b.ebn).collect::<Vec<_>>()
651        );
652
653        stl.ttis[1].ebn = 0x00;
654        assert_eq!(vec![(Some(1), "EBN")], issues(&stl));
655
656        stl.ttis[1].ebn = 0xF0;
657        assert_eq!(vec![(Some(1), "EBN")], issues(&stl));
658    }
659
660    #[test]
661    fn subtitle_without_last_block() {
662        let mut stl = stl(1);
663        stl.ttis[0].ebn = 0x00;
664        assert_eq!(vec![(Some(0), "EBN")], issues(&stl));
665    }
666
667    #[test]
668    fn last_block_must_end_with_unused_space() {
669        let mut stl = stl(1);
670        stl.ttis[0].tf = vec![b'x'; crate::text_field::TF_LENGTH];
671        assert_eq!(vec![(Some(0), "TF")], issues(&stl));
672    }
673
674    #[test]
675    fn vertical_position_of_teletext() {
676        let mut stl = stl(2);
677        stl.ttis[0].vp = 0;
678        stl.ttis[1].vp = 24;
679        assert_eq!(vec![(Some(0), "VP"), (Some(1), "VP")], issues(&stl));
680    }
681
682    #[test]
683    fn vertical_position_of_open_subtitles() {
684        let mut stl = open_stl();
685        stl.gsi.mnr = 10;
686        stl.ttis[0].vp = 10;
687        assert!(stl.validate().is_empty());
688        stl.ttis[0].vp = 11;
689        assert_eq!(vec![(Some(0), "VP")], issues(&stl));
690    }
691
692    /// A valid open subtitle list with one subtitle
693    fn open_stl() -> Stl {
694        let mut stl = Stl::new();
695        stl.gsi.dsc = DisplayStandardCode::OpenSubtitling;
696        let format = TtiFormat {
697            jc: 2,
698            vp: 5,
699            dh: false,
700        };
701        stl.add_sub(time(0, 0), time(0, 1), "Open", format).unwrap();
702        stl
703    }
704
705    #[test]
706    fn control_codes_must_match_display_standard() {
707        let mut stl = open_stl();
708        assert!(stl.validate().is_empty());
709        // Double height (0Dh) in an open subtitle
710        stl.ttis[0].tf[0] = 0x0D;
711        assert_eq!(vec![(Some(0), "TF")], issues(&stl));
712
713        let mut stl = stl_with_first_code(0x80);
714        assert_eq!(vec![(Some(0), "TF")], issues(&stl));
715        stl.gsi.dsc = DisplayStandardCode::Blank;
716        assert!(stl.validate().is_empty());
717    }
718
719    /// A teletext subtitle list with `code` as first byte of the Text Field
720    fn stl_with_first_code(code: u8) -> Stl {
721        let mut stl = stl(1);
722        stl.ttis[0].tf[0] = code;
723        stl
724    }
725
726    #[test]
727    fn reserved_text_field_codes() {
728        let stl = stl_with_first_code(0x90);
729        let issues = stl.validate();
730        assert_eq!(
731            "TTI block 0, TF: 90h is reserved for future use",
732            issues[0].to_string()
733        );
734    }
735}