Skip to main content

xil_core/script/
mod.rs

1//! Script parsing: the markdown production script → structured entries.
2//! Port of `XILP001_script_parser.py`'s library half.
3
4pub mod hints;
5pub mod sections;
6pub mod speakers;
7pub mod text;
8
9use std::path::Path;
10use std::sync::LazyLock;
11
12use indexmap::IndexMap;
13use regex::Regex;
14use serde_json::{Map, Value};
15use unicode_normalization::UnicodeNormalization;
16
17use crate::pyjson::float_repr;
18use crate::workspace::{episode_tag, show_slug};
19use hints::{parse_direction_hint, Hints};
20use sections::{get_section_map, match_section};
21use speakers::{extract_cast_from_script, load_speakers, try_match_speaker, CastEntry, Speakers};
22use text::*;
23
24static EPISODE_N: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"Episode\s+(\d+)").unwrap());
25static SEASON_OR_EPISODE: LazyLock<Regex> =
26    LazyLock::new(|| Regex::new(r"\b(?:Season\s+\d+|Episode\s+\d+)").unwrap());
27static SEASON_N: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"Season\s+(\d+)").unwrap());
28static EP_REST: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"Episode\s+\d+[:\s]+(.*)").unwrap());
29static QUOTED: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#""([^"]+)""#).unwrap());
30static ARC: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\bArc:\s*"([^"]+)""#).unwrap());
31
32/// One parsed script entry. Field order matches the pydantic model, which
33/// is the order they are serialized in.
34#[derive(Clone, Debug)]
35pub struct Entry {
36    pub seq: i64,
37    pub kind: &'static str,
38    pub section: Option<String>,
39    pub scene: Option<String>,
40    pub speaker: Option<String>,
41    pub direction: Option<String>,
42    pub text: String,
43    pub direction_type: Option<&'static str>,
44    pub sfx_source: Option<String>,
45    pub sfx_overrides: IndexMap<String, f64>,
46}
47
48impl Entry {
49    /// Serialize with every model field present, as `model_dump()` does.
50    pub fn to_json(&self) -> Value {
51        let mut m = Map::new();
52        m.insert("seq".into(), Value::from(self.seq));
53        m.insert("type".into(), Value::from(self.kind));
54        m.insert("section".into(), opt_str(&self.section));
55        m.insert("scene".into(), opt_str(&self.scene));
56        m.insert("speaker".into(), opt_str(&self.speaker));
57        m.insert("direction".into(), opt_str(&self.direction));
58        m.insert("text".into(), Value::String(self.text.clone()));
59        m.insert(
60            "direction_type".into(),
61            self.direction_type.map(Value::from).unwrap_or(Value::Null),
62        );
63        m.insert("sfx_source".into(), opt_str(&self.sfx_source));
64        m.insert(
65            "sfx_overrides".into(),
66            if self.sfx_overrides.is_empty() {
67                Value::Null
68            } else {
69                Value::Object(
70                    self.sfx_overrides
71                        .iter()
72                        .map(|(k, v)| (k.clone(), json_f64(*v)))
73                        .collect(),
74                )
75            },
76        );
77        Value::Object(m)
78    }
79}
80
81fn opt_str(v: &Option<String>) -> Value {
82    v.clone().map(Value::String).unwrap_or(Value::Null)
83}
84
85/// NFC-normalize free text the same way `parse_script` normalizes the raw
86/// script (below), so a hand-edited dialogue line and a re-parsed one can't
87/// diverge by decomposition form.
88pub fn nfc_normalize(text: &str) -> String {
89    text.nfc().collect()
90}
91
92/// Recompute the `stats` block from already-serialized entries (JSON key
93/// `"type"`, not the Rust field name `kind`). Shared by `Parsed::to_json`
94/// and by anything that mutates a single entry in an already-written
95/// `parsed_<tag>.json` (e.g. the GUI's dialogue-line editor) and needs to
96/// keep `stats` consistent with the edited `entries` array.
97pub fn stats_from_entries_json(entries: &[Value]) -> Map<String, Value> {
98    let is_kind = |e: &&Value, kind: &str| e.get("type").and_then(Value::as_str) == Some(kind);
99
100    let dialogue: Vec<&Value> = entries.iter().filter(|e| is_kind(e, "dialogue")).collect();
101    let tts_chars: usize = dialogue
102        .iter()
103        .filter_map(|e| e.get("text").and_then(Value::as_str))
104        .map(|t| t.chars().count())
105        .sum();
106
107    let mut speakers: Vec<String> = dialogue
108        .iter()
109        .filter_map(|e| e.get("speaker").and_then(Value::as_str))
110        .map(str::to_string)
111        .collect();
112    speakers.sort();
113    speakers.dedup();
114
115    let mut sections: Vec<String> = entries
116        .iter()
117        .filter_map(|e| e.get("section").and_then(Value::as_str))
118        .filter(|s| !s.is_empty())
119        .map(str::to_string)
120        .collect();
121    sections.sort();
122    sections.dedup();
123
124    let mut stats = Map::new();
125    stats.insert("total_entries".into(), Value::from(entries.len()));
126    stats.insert("dialogue_lines".into(), Value::from(dialogue.len()));
127    stats.insert(
128        "direction_lines".into(),
129        Value::from(entries.iter().filter(|e| is_kind(e, "direction")).count()),
130    );
131    stats.insert("characters_for_tts".into(), Value::from(tts_chars));
132    stats.insert(
133        "speakers".into(),
134        Value::Array(speakers.into_iter().map(Value::String).collect()),
135    );
136    stats.insert(
137        "sections".into(),
138        Value::Array(sections.into_iter().map(Value::String).collect()),
139    );
140    stats
141}
142
143/// A float that serializes the way Python writes it (`20.0`, not `20`).
144fn json_f64(v: f64) -> Value {
145    serde_json::Number::from_f64(v)
146        .map(Value::Number)
147        .unwrap_or(Value::Null)
148}
149
150/// Header metadata: show, season, episode, title, season title.
151#[derive(Debug, PartialEq)]
152pub struct Header {
153    pub show: String,
154    pub season: Option<i64>,
155    pub episode: i64,
156    pub title: String,
157    pub season_title: Option<String>,
158}
159
160/// Parse the first non-empty script line. `None` when it carries no
161/// `Episode N`.
162pub fn parse_script_header(line: &str) -> Option<Header> {
163    let ep = EPISODE_N.captures(line)?;
164    let show = match SEASON_OR_EPISODE.find(line) {
165        Some(m) => line[..m.start()].trim().to_string(),
166        None => "Unknown Show".to_string(),
167    };
168    let season = SEASON_N.captures(line).and_then(|c| c[1].parse().ok());
169    let episode: i64 = ep[1].parse().unwrap_or(1);
170    let title = match EP_REST.captures(line) {
171        Some(c) => {
172            let rest = c[1].to_string();
173            match QUOTED.captures(&rest) {
174                Some(q) => q[1].to_string(),
175                None => rest.trim().to_string(),
176            }
177        }
178        None => String::new(),
179    };
180    let season_title = ARC.captures(line).map(|c| c[1].to_string());
181    Some(Header {
182        show,
183        season,
184        episode,
185        title,
186        season_title,
187    })
188}
189
190/// Everything `parse_script` produces.
191pub struct Parsed {
192    pub show: String,
193    pub season: Option<i64>,
194    pub episode: i64,
195    pub title: String,
196    pub season_title: Option<String>,
197    pub source_file: String,
198    pub entries: Vec<Entry>,
199    /// `(1-based line number, raw line, entry index)` for the debug CSV.
200    pub debug_line_map: Vec<(usize, String, usize)>,
201}
202
203impl Parsed {
204    pub fn tag(&self) -> String {
205        episode_tag(self.season, self.episode)
206    }
207
208    /// The full `model_dump()` shape, ready for the JSON writer.
209    pub fn to_json(&self) -> Value {
210        let entries_json: Vec<Value> = self.entries.iter().map(Entry::to_json).collect();
211        let stats = stats_from_entries_json(&entries_json);
212
213        let mut m = Map::new();
214        m.insert("show".into(), Value::String(self.show.clone()));
215        m.insert(
216            "season".into(),
217            self.season.map(Value::from).unwrap_or(Value::Null),
218        );
219        m.insert("episode".into(), Value::from(self.episode));
220        m.insert("title".into(), Value::String(self.title.clone()));
221        m.insert("season_title".into(), opt_str(&self.season_title));
222        m.insert(
223            "source_file".into(),
224            Value::String(self.source_file.clone()),
225        );
226        m.insert("entries".into(), Value::Array(entries_json));
227        m.insert("stats".into(), Value::Object(stats));
228        Value::Object(m)
229    }
230}
231
232/// Options the caller supplies; `season`/`season_title` are the
233/// `project.json` fallbacks already resolved.
234pub struct ParseOpts<'a> {
235    pub project_type: &'a str,
236    pub speakers_path: Option<&'a Path>,
237    /// Applied when the script header declares no season.
238    pub season_fallback: Option<i64>,
239    /// Applied when the script header declares no arc.
240    pub season_title_fallback: Option<String>,
241}
242
243/// Parse a markdown production script into sequence-numbered entries.
244///
245/// `warn` receives the messages Python logs for malformed pipe-hints.
246pub fn parse_script(
247    raw: &str,
248    source_file: &str,
249    opts: &ParseOpts,
250    warn: &mut dyn FnMut(String),
251) -> Parsed {
252    let section_map = get_section_map(opts.project_type);
253
254    // NFC first: an accented character can arrive decomposed depending on the
255    // editor, which would make an otherwise identical cue a different string
256    // from its config key and its asset filename.
257    let raw: String = raw.nfc().collect();
258    let raw = strip_markdown_escapes(&raw);
259    let raw = strip_markdown_formatting(&raw);
260    let lines: Vec<String> = raw.split('\n').map(str::to_string).collect();
261
262    let mut entries: Vec<Entry> = Vec::new();
263    let mut debug_line_map: Vec<(usize, String, usize)> = Vec::new();
264    let mut seq: i64 = 0;
265    let mut current_section: Option<String> = None;
266    let mut current_scene: Option<String> = None;
267    let mut in_metadata = false;
268    let mut last_dialogue_idx: Option<usize> = None;
269    let mut pending_speaker: Option<(String, Option<String>)> = None;
270
271    // The header is the first NON-EMPTY line: a stray leading blank must not
272    // send us to the "Unknown Show / S01E01" defaults.
273    let mut hdr_idx = 0;
274    while hdr_idx < lines.len() && lines[hdr_idx].trim().is_empty() {
275        hdr_idx += 1;
276    }
277    let first_line = lines.get(hdr_idx).map(|l| l.trim()).unwrap_or("");
278    let header = if first_line.is_empty() {
279        None
280    } else {
281        parse_script_header(first_line)
282    };
283    let mut start = 0;
284    let (show, mut season, episode, title, mut season_title) = match header {
285        Some(h) => {
286            start = hdr_idx + 1;
287            (h.show, h.season, h.episode, h.title, h.season_title)
288        }
289        None => ("Unknown Show".to_string(), None, 1, String::new(), None),
290    };
291
292    let script_slug = show_slug(&show);
293    if season.is_none() {
294        season = opts.season_fallback;
295    }
296    if season_title.is_none() {
297        season_title = opts.season_title_fallback.clone();
298    }
299
300    // The CAST block seeds speaker recognition, then start advances past it.
301    let cast_entries: Vec<CastEntry> = extract_cast_from_script(&lines[start.min(lines.len())..]);
302    let mut in_cast = false;
303    for (i, raw) in lines.iter().enumerate().skip(start) {
304        let line = raw.trim();
305        if line == "CAST:" {
306            in_cast = true;
307            continue;
308        }
309        if in_cast && (line == "===" || (!line.is_empty() && !line.starts_with('*'))) {
310            start = i;
311            break;
312        }
313    }
314
315    let speakers = load_speakers(opts.speakers_path, &cast_entries);
316
317    for (i, raw_line) in lines.iter().enumerate().skip(start) {
318        let line = raw_line.trim().to_string();
319        if line.is_empty() {
320            continue;
321        }
322
323        // Multi-line dialogue: a speaker name is waiting for its text.
324        if let Some((p_key, p_direction)) = pending_speaker.clone() {
325            if line.starts_with('(') && line.ends_with(')') {
326                let d = line[1..line.len() - 1].trim().to_string();
327                pending_speaker = Some((p_key, Some(d)));
328                continue;
329            }
330            // A direction between the name and the dialogue is emitted, and
331            // the pending speaker stays alive for the next line.
332            if is_stage_direction(&line) {
333                emit_directions(
334                    &line,
335                    i,
336                    raw_line,
337                    &script_slug,
338                    &current_section,
339                    &current_scene,
340                    &mut seq,
341                    &mut entries,
342                    &mut debug_line_map,
343                    warn,
344                );
345                continue;
346            }
347            seq += 1;
348            entries.push(Entry {
349                seq,
350                kind: "dialogue",
351                section: current_section.clone(),
352                scene: current_scene.clone(),
353                speaker: Some(p_key),
354                direction: p_direction,
355                text: line.clone(),
356                direction_type: None,
357                sfx_source: None,
358                sfx_overrides: IndexMap::new(),
359            });
360            debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
361            last_dialogue_idx = Some(entries.len() - 1);
362            pending_speaker = None;
363            continue;
364        }
365
366        if is_divider(&line) {
367            continue;
368        }
369        if is_metadata_section(&line) {
370            in_metadata = true;
371            continue;
372        }
373        if in_metadata {
374            continue;
375        }
376        if line.starts_with("END OF EPISODE") || line.starts_with("END OF PRODUCTION") {
377            break;
378        }
379
380        if let Some(slug) = match_section(&line, &section_map) {
381            current_section = Some(slug);
382            current_scene = None;
383            seq += 1;
384            entries.push(Entry {
385                seq,
386                kind: "section_header",
387                section: current_section.clone(),
388                scene: None,
389                speaker: None,
390                direction: None,
391                text: line.trim().to_string(),
392                direction_type: None,
393                sfx_source: None,
394                sfx_overrides: IndexMap::new(),
395            });
396            debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
397            last_dialogue_idx = None;
398            continue;
399        }
400
401        if is_scene_header(&line) {
402            let (scene_num, _) = parse_scene_header(&line);
403            if let Some(n) = scene_num {
404                current_scene = Some(format!("scene-{n}"));
405            }
406            seq += 1;
407            entries.push(Entry {
408                seq,
409                kind: "scene_header",
410                section: current_section.clone(),
411                scene: current_scene.clone(),
412                speaker: None,
413                direction: None,
414                text: strip_brackets(&line),
415                direction_type: None,
416                sfx_source: None,
417                sfx_overrides: IndexMap::new(),
418            });
419            debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
420            emit_directions(
421                &line,
422                i,
423                raw_line,
424                &script_slug,
425                &current_section,
426                &current_scene,
427                &mut seq,
428                &mut entries,
429                &mut debug_line_map,
430                warn,
431            );
432            last_dialogue_idx = None;
433            continue;
434        }
435
436        if is_stage_direction(&line) {
437            emit_directions(
438                &line,
439                i,
440                raw_line,
441                &script_slug,
442                &current_section,
443                &current_scene,
444                &mut seq,
445                &mut entries,
446                &mut debug_line_map,
447                warn,
448            );
449            last_dialogue_idx = None;
450            continue;
451        }
452
453        if let Some(m) = try_match_speaker(&line, &speakers) {
454            if !m.text.is_empty() {
455                seq += 1;
456                entries.push(Entry {
457                    seq,
458                    kind: "dialogue",
459                    section: current_section.clone(),
460                    scene: current_scene.clone(),
461                    speaker: Some(m.key),
462                    direction: m.direction,
463                    text: m.text,
464                    direction_type: None,
465                    sfx_source: None,
466                    sfx_overrides: IndexMap::new(),
467                });
468                debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
469                last_dialogue_idx = Some(entries.len() - 1);
470            } else {
471                // Speaker name alone: direction and text follow on later lines.
472                pending_speaker = Some((m.key, m.direction));
473                last_dialogue_idx = None;
474            }
475            continue;
476        }
477
478        // Continuation of the previous dialogue line.
479        if let Some(idx) = last_dialogue_idx {
480            if entries[idx].kind == "dialogue" {
481                // A standalone parenthetical is an acting note, not speech.
482                if line.starts_with('(') && line.ends_with(')') {
483                    continue;
484                }
485                entries[idx].text.push(' ');
486                entries[idx].text.push_str(&line);
487                continue;
488            }
489        }
490        // Anything else is skipped silently.
491    }
492
493    Parsed {
494        show,
495        season,
496        episode,
497        title,
498        season_title,
499        source_file: source_file.to_string(),
500        entries,
501        debug_line_map,
502    }
503}
504
505/// Emit one direction entry per recognized bracket on a line.
506#[allow(clippy::too_many_arguments)]
507fn emit_directions(
508    line: &str,
509    i: usize,
510    raw_line: &str,
511    slug: &str,
512    section: &Option<String>,
513    scene: &Option<String>,
514    seq: &mut i64,
515    entries: &mut Vec<Entry>,
516    debug_line_map: &mut Vec<(usize, String, usize)>,
517    warn: &mut dyn FnMut(String),
518) {
519    for bracket_text in find_brackets(line) {
520        let Hints {
521            clean,
522            source,
523            overrides,
524        } = parse_direction_hint(bracket_text.trim(), slug, warn);
525        let Some(direction_type) = classify_direction(&clean) else {
526            // An acting note in square brackets, not a technical cue.
527            continue;
528        };
529        *seq += 1;
530        entries.push(Entry {
531            seq: *seq,
532            kind: "direction",
533            section: section.clone(),
534            scene: scene.clone(),
535            speaker: None,
536            direction: None,
537            text: clean,
538            direction_type: Some(direction_type),
539            sfx_source: source,
540            sfx_overrides: overrides,
541        });
542        debug_line_map.push((i + 1, raw_line.to_string(), entries.len() - 1));
543    }
544}
545
546/// Per-speaker dialogue distribution, sorted by lines descending.
547pub struct SpeakerStat {
548    pub speaker: String,
549    pub lines: usize,
550    pub words: usize,
551    pub chars: usize,
552    pub pct_lines: f64,
553    pub pct_words: f64,
554    pub pct_chars: f64,
555}
556
557pub fn compute_speaker_stats(entries: &[Entry]) -> Vec<SpeakerStat> {
558    let mut accum: IndexMap<String, (usize, usize, usize)> = IndexMap::new();
559    for e in entries.iter().filter(|e| e.kind == "dialogue") {
560        let sp = e.speaker.clone().unwrap_or_default();
561        let slot = accum.entry(sp).or_insert((0, 0, 0));
562        slot.0 += 1;
563        slot.1 += e.text.split_whitespace().count();
564        slot.2 += e.text.chars().count();
565    }
566    let total_lines = accum.values().map(|v| v.0).sum::<usize>().max(1) as f64;
567    let total_words = accum.values().map(|v| v.1).sum::<usize>().max(1) as f64;
568    let total_chars = accum.values().map(|v| v.2).sum::<usize>().max(1) as f64;
569
570    let mut out: Vec<SpeakerStat> = accum
571        .into_iter()
572        .map(|(speaker, (lines, words, chars))| SpeakerStat {
573            speaker,
574            lines,
575            words,
576            chars,
577            pct_lines: py_round1(lines as f64 / total_lines * 100.0),
578            pct_words: py_round1(words as f64 / total_words * 100.0),
579            pct_chars: py_round1(chars as f64 / total_chars * 100.0),
580        })
581        .collect();
582    // Python's sort is stable, so equal line counts keep insertion order.
583    out.sort_by_key(|a| std::cmp::Reverse(a.lines));
584    out
585}
586
587/// `round(x, 1)` — banker's rounding on the decimal representation, as
588/// CPython does it.
589fn py_round1(x: f64) -> f64 {
590    let s = format!("{:.1}", x);
591    s.parse().unwrap_or(x)
592}
593
594/// `float_repr` re-exported for callers writing stat values into JSON.
595pub fn repr_f64(v: f64) -> String {
596    float_repr(v)
597}
598
599/// Speaker display order used by the cast skeleton.
600pub fn speakers_for_cast(speakers: &Speakers) -> IndexMap<String, String> {
601    speakers::key_to_display(speakers)
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    fn parse(src: &str) -> Parsed {
609        let opts = ParseOpts {
610            project_type: "podcast",
611            speakers_path: Some(Path::new("/nonexistent")),
612            season_fallback: None,
613            season_title_fallback: None,
614        };
615        parse_script(src, "t.md", &opts, &mut |_| {})
616    }
617
618    #[test]
619    fn header_shapes() {
620        let h =
621            parse_script_header(r#"THE 413 Season 1: Episode 2: "The Booth" Arc: "Holiday Shift""#)
622                .unwrap();
623        assert_eq!(
624            h,
625            Header {
626                show: "THE 413".into(),
627                season: Some(1),
628                episode: 2,
629                title: "The Booth".into(),
630                season_title: Some("Holiday Shift".into()),
631            }
632        );
633        let bare = parse_script_header("My Show Episode 7: Untitled Thing").unwrap();
634        assert_eq!(
635            (bare.season, bare.episode, bare.title.as_str()),
636            (None, 7, "Untitled Thing")
637        );
638        assert!(parse_script_header("Just a line").is_none());
639    }
640
641    #[test]
642    fn leading_blank_lines_do_not_defeat_the_header() {
643        let p = parse("\n\n\nMy Show Episode 3: \"T\"\n\nCOLD OPEN\n");
644        assert_eq!((p.show.as_str(), p.episode), ("My Show", 3));
645    }
646
647    #[test]
648    fn dialogue_direction_and_continuation() {
649        let p = parse(
650            "S Episode 1: \"T\"\n\nCOLD OPEN\n\nSCENE 1: ROOM\n\n\
651             [SFX: DOOR OPENS | door.mp3]\n\n\
652             ADAM (quietly)\nFirst line.\nsecond line continues.\n(beat)\nthird line.\n",
653        );
654        let kinds: Vec<&str> = p.entries.iter().map(|e| e.kind).collect();
655        assert_eq!(
656            kinds,
657            vec!["section_header", "scene_header", "direction", "dialogue"]
658        );
659        let d = &p.entries[2];
660        assert_eq!(d.text, "SFX: DOOR OPENS");
661        assert_eq!(d.sfx_source.as_deref(), Some("SFX/s/door.mp3"));
662        assert_eq!(d.direction_type, Some("SFX"));
663        let dial = &p.entries[3];
664        assert_eq!(dial.speaker.as_deref(), Some("adam"));
665        assert_eq!(dial.direction.as_deref(), Some("quietly"));
666        assert_eq!(
667            dial.text, "First line. second line continues. third line.",
668            "(beat) is dropped"
669        );
670        assert_eq!(dial.scene.as_deref(), Some("scene-1"));
671        assert_eq!(dial.section.as_deref(), Some("cold-open"));
672    }
673
674    #[test]
675    fn direction_between_speaker_and_text_keeps_the_speaker_pending() {
676        let p = parse("S Episode 1: \"T\"\n\nADAM\n[BEAT]\nThe line.\n");
677        let kinds: Vec<&str> = p.entries.iter().map(|e| e.kind).collect();
678        assert_eq!(kinds, vec!["direction", "dialogue"]);
679        assert_eq!(p.entries[1].speaker.as_deref(), Some("adam"));
680        assert_eq!(p.entries[1].text, "The line.");
681        assert_eq!(
682            p.entries.iter().map(|e| e.seq).collect::<Vec<_>>(),
683            vec![1, 2]
684        );
685    }
686
687    #[test]
688    fn standalone_parenthetical_becomes_the_direction() {
689        let p = parse("S Episode 1: \"T\"\n\nADAM\n(shouting)\nHey!\n");
690        assert_eq!(p.entries[0].direction.as_deref(), Some("shouting"));
691        assert_eq!(p.entries[0].text, "Hey!");
692    }
693
694    #[test]
695    fn scene_header_emits_its_embedded_directions() {
696        let p = parse("S Episode 1: \"T\"\n\nSCENE 2A: THE ALLEY [AMBIENCE: rain]\n");
697        assert_eq!(p.entries.len(), 2);
698        assert_eq!(p.entries[0].text, "SCENE 2A: THE ALLEY");
699        assert_eq!(p.entries[0].scene.as_deref(), Some("scene-2A"));
700        assert_eq!(p.entries[1].text, "AMBIENCE: rain");
701        assert_eq!(p.entries[1].direction_type, Some("AMBIENCE"));
702    }
703
704    #[test]
705    fn parsing_stops_at_end_markers_and_metadata() {
706        let p = parse("S Episode 1: \"T\"\n\nADAM Yes.\n\nEND OF EPISODE\n\nADAM No.\n");
707        assert_eq!(p.entries.len(), 1);
708        let m = parse("S Episode 1: \"T\"\n\nADAM Yes.\n\nPRODUCTION NOTES:\n\nADAM No.\n");
709        assert_eq!(m.entries.len(), 1);
710    }
711
712    #[test]
713    fn unrecognized_brackets_are_skipped_without_consuming_a_seq() {
714        let p = parse("S Episode 1: \"T\"\n\n[drawn out]\n[SFX: X]\n");
715        assert_eq!(p.entries.len(), 1);
716        assert_eq!(p.entries[0].seq, 1);
717    }
718
719    #[test]
720    fn stats_count_characters_not_bytes() {
721        let p = parse("S Episode 1: \"T\"\n\nADAM Café.\n");
722        let j = p.to_json();
723        assert_eq!(j["stats"]["characters_for_tts"], 5);
724        assert_eq!(j["stats"]["dialogue_lines"], 1);
725        assert_eq!(j["stats"]["speakers"], serde_json::json!(["adam"]));
726    }
727
728    #[test]
729    fn entry_json_carries_every_model_field() {
730        let p = parse("S Episode 1: \"T\"\n\n[SFX: X | a.mp3 | play_volume_pct=20]\n");
731        let e = &p.to_json()["entries"][0];
732        assert_eq!(e["sfx_source"], "SFX/s/a.mp3");
733        assert_eq!(
734            e["sfx_overrides"],
735            serde_json::json!({"volume_percentage": 20.0})
736        );
737        assert_eq!(e["speaker"], Value::Null);
738        assert!(e.as_object().unwrap().contains_key("direction"));
739    }
740
741    #[test]
742    fn speaker_stats_sorted_and_rounded() {
743        let p = parse("S Episode 1: \"T\"\n\nADAM one two three.\nMAYA hi.\nADAM four.\n");
744        let s = compute_speaker_stats(&p.entries);
745        assert_eq!(s[0].speaker, "adam");
746        assert_eq!((s[0].lines, s[0].words), (2, 4));
747        assert_eq!(s[0].pct_lines, 66.7);
748        assert_eq!(s[1].pct_lines, 33.3);
749    }
750}