Skip to main content

shuyi_cli/
lang_status.rs

1//! `lang-status <tag>`: what a language pack of the game lacks:
2//! the **missing** keys (no entry), the **stale** ones (an entry made
3//! from source text that has since changed) and the **orphaned** dialogue
4//! entries (their line was reworded or cut, so it has a new id or none),
5//! each with the untranslated line of its scene nearest to what it
6//! translated. All show in the game as a shorter tag's pack or the source
7//! language has them ([`Lang::chain`]) and fail no gate; this lists them
8//! for whoever translates. After the screen text's missing and
9//! stale keys come its entries **too long** for their place: wider than
10//! their key's `max_cells` in the source's `ui.ron` ([`Lang::overflow`]),
11//! measured with the font atlas's wide glyphs. They fail no gate either. Screen text comes first, then the
12//! data's, then the dialogue's, each with its counts. Test text (the
13//! `test*.dlg` scenes, data ids starting with `test`) is left out, and
14//! counted, except in the report of the test pack (code `test`); `--only`
15//! and `--file` narrow the report. The game gives its languages
16//! ([`crate::GameTools::lang`]).
17
18use std::fmt::Write as _;
19
20use shuyi_assets::font::CellWidths;
21use shuyi_assets::lang::{Lang, LangCode, LineText, MadeBy, Orphan, Overflow};
22
23/// The code of the pack whose report keeps the test text.
24pub const TEST_PACK: &str = "test";
25
26/// The three parts of a pack's text, in the order the report gives them.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Part {
29    /// `ui.ron`: the screens' text.
30    Screen,
31    /// `data.ron`: names, tips, titles and notes.
32    Data,
33    /// `dialogue/`: lines and captions.
34    Dialogue,
35}
36
37impl Part {
38    const ALL: [Self; 3] = [Self::Screen, Self::Data, Self::Dialogue];
39
40    fn heading(self) -> &'static str {
41        match self {
42            Self::Screen => "screen text",
43            Self::Data => "data",
44            Self::Dialogue => "dialogue",
45        }
46    }
47}
48
49/// What to narrow the report to.
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
51pub struct Options {
52    /// Only this part (`--only ui|data|dialogue`).
53    pub only: Option<Part>,
54    /// Only the lines and captions of this `.dlg` file, by stem
55    /// (`--file ch01`); this is dialogue only.
56    pub file: Option<String>,
57}
58
59/// The pack code and options of `lang-status`'s arguments.
60pub fn parse_args(args: &[String]) -> Result<(String, Options), String> {
61    let mut code = None;
62    let mut options = Options::default();
63    let mut args = args.iter();
64    while let Some(arg) = args.next() {
65        match arg.as_str() {
66            "--only" => {
67                options.only = Some(match args.next().map(String::as_str) {
68                    Some("ui") => Part::Screen,
69                    Some("data") => Part::Data,
70                    Some("dialogue") => Part::Dialogue,
71                    _ => return Err("--only takes ui, data or dialogue".to_owned()),
72                });
73            }
74            "--file" => match args.next() {
75                Some(stem) => options.file = Some(stem.trim_end_matches(".dlg").to_owned()),
76                None => return Err("--file takes a .dlg file name, like ch01".to_owned()),
77            },
78            flag if flag.starts_with("--") => return Err(format!("unknown option {flag}")),
79            _ if code.is_none() => code = Some(arg.clone()),
80            _ => return Err("only one language code".to_owned()),
81        }
82    }
83    if options.file.is_some() && options.only.is_some_and(|part| part != Part::Dialogue) {
84        return Err("--file is dialogue; it can't go with --only ui or data".to_owned());
85    }
86    let code = code.ok_or("no language code")?;
87    Ok((code, options))
88}
89
90/// What the report counts and prints for one pack.
91struct Scope<'a> {
92    lang: &'a Lang,
93    options: &'a Options,
94    /// Whether test text counts (it does for the test pack only).
95    tests: bool,
96    /// Missing and stale test texts left out so far.
97    left_out: usize,
98}
99
100impl Scope<'_> {
101    fn part_of(&self, key: &str) -> Part {
102        if self.lang.has(key) {
103            Part::Screen
104        } else if self.lang.source().lines.contains_key(key) {
105            Part::Dialogue
106        } else {
107            Part::Data
108        }
109    }
110
111    fn shows(&self, part: Part) -> bool {
112        let file = self.options.file.as_ref().map(|_| Part::Dialogue);
113        self.options
114            .only
115            .or(file)
116            .is_none_or(|wanted| wanted == part)
117    }
118
119    /// Whether the report lists `key`; counts it when it is left out as
120    /// test text.
121    fn keeps(&mut self, key: &str) -> bool {
122        let part = self.part_of(key);
123        if !self.shows(part) {
124            return false;
125        }
126        let line = self.lang.source().lines.get(key);
127        if let (Some(stem), Some(line)) = (&self.options.file, line)
128            && line.file != *stem
129        {
130            return false;
131        }
132        // Test text is the `test*.dlg` scenes' and the data whose id
133        // starts with `test` (`names.test_guide`, `chapters.test.title`).
134        let test = match (part, line) {
135            (Part::Dialogue, Some(line)) => line.file.starts_with("test"),
136            (Part::Data, _) => key
137                .split('.')
138                .nth(1)
139                .is_some_and(|id| id.starts_with("test")),
140            _ => false,
141        };
142        if test && !self.tests {
143            self.left_out += 1;
144            return false;
145        }
146        true
147    }
148
149    fn keeps_orphan(&self, orphan: &Orphan) -> bool {
150        if !self.shows(Part::Dialogue) {
151            return false;
152        }
153        match &self.options.file {
154            None => true,
155            Some(stem) => orphan
156                .nearest
157                .as_ref()
158                .and_then(|key| self.lang.source().lines.get(key))
159                .is_some_and(|line| line.file == *stem),
160        }
161    }
162}
163
164/// The report for the pack `code` of `lang` (a tag in any spelling
165/// [`LangCode::parse`] reads: `pt_br` is `pt-BR`), or why there is none.
166/// `variants` are a line's variants, in the pack files' order; `widths`
167/// measure the screen text against its places (the font atlas's,
168/// [`CellWidths::of`], or [`CellWidths::NARROW`]).
169pub fn report<S: AsRef<str>>(
170    lang: &Lang,
171    variants: &[S],
172    widths: &CellWidths,
173    code: &str,
174    options: &Options,
175) -> Result<String, String> {
176    let known = || {
177        let codes: Vec<&str> = lang.codes().map(LangCode::as_str).collect();
178        format!("the packs are: {}", codes.join(", "))
179    };
180    let code = LangCode::parse(code).map_err(|_| format!("no language \"{code}\"; {}", known()))?;
181    let (Some(pack), Some(status)) = (lang.pack(&code), lang.status(&code)) else {
182        return Err(format!("no language pack \"{code}\"; {}", known()));
183    };
184    let made_by = match &pack.info.made_by {
185        MadeBy::Machine => "machine-made".to_owned(),
186        MadeBy::Human(credit) => format!("by {credit}"),
187    };
188    let mut scope = Scope {
189        lang,
190        options,
191        tests: code.as_str() == TEST_PACK,
192        left_out: 0,
193    };
194    let missing: Vec<&String> = status.missing.iter().filter(|k| scope.keeps(k)).collect();
195    let stale: Vec<&String> = status.stale.iter().filter(|k| scope.keeps(k)).collect();
196    let overflow = if scope.shows(Part::Screen) {
197        lang.overflow(&code, widths).unwrap_or_default()
198    } else {
199        Vec::new()
200    };
201    let orphans: Vec<&Orphan> = status
202        .orphans
203        .iter()
204        .filter(|o| scope.keeps_orphan(o))
205        .collect();
206    let mut out = format!(
207        "lang-status {code} ({}, {made_by}): {} missing, {} stale, {} orphaned",
208        pack.info.name,
209        missing.len(),
210        stale.len(),
211        orphans.len()
212    );
213    if scope.left_out > 0 {
214        // Writing to a `String` can't fail.
215        let _ = write!(out, "; {} test texts left out", scope.left_out);
216    }
217    out.push('\n');
218    for part in Part::ALL.into_iter().filter(|&part| scope.shows(part)) {
219        let of_part = |keys: &[&String]| -> Vec<String> {
220            keys.iter()
221                .filter(|k| scope.part_of(k) == part)
222                .map(|k| (*k).clone())
223                .collect()
224        };
225        let (missing, stale) = (of_part(&missing), of_part(&stale));
226        let orphans: &[&Orphan] = if part == Part::Dialogue {
227            &orphans
228        } else {
229            &[]
230        };
231        let _ = write!(
232            out,
233            "{}: {} missing, {} stale",
234            part.heading(),
235            missing.len(),
236            stale.len()
237        );
238        match part {
239            Part::Screen => {
240                let _ = write!(out, ", {} too long", overflow.len());
241            }
242            Part::Dialogue => {
243                let _ = write!(out, ", {} orphaned", orphans.len());
244            }
245            Part::Data => {}
246        }
247        out.push('\n');
248        if !missing.is_empty() {
249            out.push_str("missing:\n");
250        }
251        for key in &missing {
252            let source = lang.source_text(key).unwrap_or_default();
253            let _ = writeln!(out, "  {key}\n    source: {source:?}");
254        }
255        if !stale.is_empty() {
256            out.push_str("stale:\n");
257        }
258        for key in &stale {
259            let entry = pack.entry(key).map(|e| &e.source);
260            let was = entry.or_else(|| pack.line(key).map(|e| &e.source));
261            let was = was.map_or("", String::as_str);
262            let now = lang.source_text(key).unwrap_or_default();
263            let _ = writeln!(out, "  {key}\n    was: {was:?}\n    now: {now:?}");
264        }
265        if part == Part::Screen {
266            write_overflow(&mut out, &overflow);
267        }
268        if !orphans.is_empty() {
269            out.push_str("orphaned:\n");
270        }
271        for orphan in orphans {
272            write_orphan(&mut out, lang, variants, orphan);
273        }
274    }
275    Ok(out)
276}
277
278fn write_overflow(out: &mut String, overflow: &[Overflow]) {
279    if !overflow.is_empty() {
280        out.push_str("too long:\n");
281    }
282    for entry in overflow {
283        let _ = writeln!(
284            out,
285            "  {}\n    {} cells; the place has {}\n    text: {:?}",
286            entry.key, entry.width, entry.max_cells, entry.text
287        );
288    }
289}
290
291fn write_orphan<S: AsRef<str>>(out: &mut String, lang: &Lang, variants: &[S], orphan: &Orphan) {
292    let _ = writeln!(out, "  {}\n    was: {:?}", orphan.key, orphan.source);
293    match &orphan.text {
294        LineText::One(text) => {
295            let _ = writeln!(out, "    text: {text:?}");
296        }
297        LineText::Variants(texts) => {
298            // In the pack files' order.
299            for variant in variants {
300                let variant = variant.as_ref();
301                let text = texts.get(variant).map_or("", String::as_str);
302                let _ = writeln!(out, "    text_{variant}: {text:?}");
303            }
304        }
305    }
306    match &orphan.nearest {
307        Some(key) => {
308            let now = lang.source_text(key).unwrap_or_default();
309            let _ = writeln!(out, "    nearest: {key}\n    now: {now:?}");
310        }
311        None => out.push_str("    nearest: no untranslated line in its scene\n"),
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use std::collections::BTreeMap;
318
319    use shuyi_assets::dialogue::{DialogueLimits, DialogueTable, NoTokens, parse_dlg};
320    use shuyi_assets::lang::{
321        LangFile, PackRules, SourceText, english_from_source, from_files, pack_from_sources,
322    };
323
324    use super::*;
325
326    /// A data text the rules never refuse.
327    fn no_problem(_: &str, _: &str) -> Option<String> {
328        None
329    }
330
331    /// A game's pack rules: no tokens, no variants.
332    const RULES: PackRules<'static> = PackRules {
333        data_problem: no_problem,
334        tokens: &NoTokens,
335        limits: DialogueLimits {
336            box_width: 40,
337            box_lines: 3,
338            max_text: 200,
339            max_option: 30,
340            min_options: 2,
341            max_options: 4,
342            max_reaction_texts: 3,
343        },
344        widths: &shuyi_assets::font::CellWidths::NARROW,
345        variants: &[],
346    };
347
348    /// No variants: one text per line.
349    const ONE_TEXT: [&str; 0] = [];
350
351    fn report(lang: &Lang, code: &str, options: &Options) -> Result<String, String> {
352        super::report(lang, &ONE_TEXT, &CellWidths::NARROW, code, options)
353    }
354
355    fn lang(info: &str, ui: &str) -> Lang {
356        let english = r#"{"a.one": "One", "a.two": "Two", "a.three": "Three"}"#;
357        let english = english_from_source("en/ui.ron", english).unwrap();
358        let pack = pack_from_sources(("l", info), ("u", ui), &english, &RULES).unwrap();
359        let packs = BTreeMap::from([(LangCode::new("xx").unwrap(), pack)]);
360        Lang::new(english, packs)
361    }
362
363    #[test]
364    fn lists_missing_and_stale_keys() {
365        let ui = r#"[
366            (key: "a.one", source: "One", text: "Un"),
367            (key: "a.two", source: "Too", text: "Deux"),
368        ]"#;
369        let lang = lang(r#"(name: "Xx", made_by: Machine)"#, ui);
370        assert_eq!(
371            report(&lang, "xx", &Options::default()).unwrap(),
372            "lang-status xx (Xx, machine-made): 1 missing, 1 stale, 0 orphaned\n\
373             screen text: 1 missing, 1 stale, 0 too long\n\
374             missing:\n  a.three\n    source: \"Three\"\n\
375             stale:\n  a.two\n    was: \"Too\"\n    now: \"Two\"\n\
376             data: 0 missing, 0 stale\n\
377             dialogue: 0 missing, 0 stale, 0 orphaned\n"
378        );
379    }
380
381    #[test]
382    fn a_complete_pack_is_one_line() {
383        let ui = r#"[
384            (key: "a.one", source: "One", text: "Un"),
385            (key: "a.two", source: "Two", text: "Deux"),
386            (key: "a.three", source: "Three", text: "Trois"),
387        ]"#;
388        let lang = lang(r#"(name: "Xx", made_by: Human("A. Translator"))"#, ui);
389        assert_eq!(
390            report(&lang, "xx", &Options::default()).unwrap(),
391            "lang-status xx (Xx, by A. Translator): 0 missing, 0 stale, 0 orphaned\n\
392             screen text: 0 missing, 0 stale, 0 too long\n\
393             data: 0 missing, 0 stale\n\
394             dialogue: 0 missing, 0 stale, 0 orphaned\n"
395        );
396    }
397
398    /// An entry made too long on purpose for its key's place is listed,
399    /// measured with the atlas's widths; `--only data` leaves it out.
400    #[test]
401    fn lists_the_screen_text_too_long_for_its_place() {
402        let english = r#"{
403            "a.one": (text: "One", max_cells: 4, note: "A button."),
404            "a.two": (text: "Two", max_cells: 5),
405            "a.three": "Three",
406        }"#;
407        let ui = r#"[
408            (key: "a.one", source: "One", text: "Einhundert"),
409            (key: "a.two", source: "Two", text: "にばん"),
410            (key: "a.three", source: "Three", text: "Dreihundertdreiunddreißig"),
411        ]"#;
412        let files = [
413            ("en/ui.ron", english),
414            ("xx/lang.ron", r#"(name: "Xx", made_by: Machine)"#),
415            ("xx/ui.ron", ui),
416        ]
417        .map(|(path, source)| LangFile {
418            path,
419            source: Some(source),
420        });
421        let lang = from_files(&files, None, str::to_owned, &RULES).unwrap();
422        let wide = CellWidths::wide("にばん".chars());
423        let said = super::report(&lang, &ONE_TEXT, &wide, "xx", &Options::default()).unwrap();
424        assert_eq!(
425            said,
426            "lang-status xx (Xx, machine-made): 0 missing, 0 stale, 0 orphaned\n\
427             screen text: 0 missing, 0 stale, 2 too long\n\
428             too long:\n\
429             \x20 a.one\n    10 cells; the place has 4\n    text: \"Einhundert\"\n\
430             \x20 a.two\n    6 cells; the place has 5\n    text: \"にばん\"\n\
431             data: 0 missing, 0 stale\n\
432             dialogue: 0 missing, 0 stale, 0 orphaned\n"
433        );
434        let only_data = Options {
435            only: Some(Part::Data),
436            ..Options::default()
437        };
438        let said = super::report(&lang, &ONE_TEXT, &wide, "xx", &only_data).unwrap();
439        assert!(!said.contains("too long"), "{said}");
440    }
441
442    #[test]
443    fn an_unknown_language_names_the_packs() {
444        let lang = lang(r#"(name: "Xx", made_by: Machine)"#, "[]");
445        assert_eq!(
446            report(&lang, "zz", &Options::default()).unwrap_err(),
447            "no language pack \"zz\"; the packs are: xx"
448        );
449        // English is the source, not a pack.
450        assert!(report(&lang, "en", &Options::default()).is_err());
451        assert_eq!(
452            report(&lang, "Not A Code", &Options::default()).unwrap_err(),
453            "no language \"Not A Code\"; the packs are: xx"
454        );
455    }
456
457    /// A tag is read in any spelling: `pt-br` is the `pt-BR` pack.
458    #[test]
459    fn a_tag_in_another_spelling_names_its_pack() {
460        let english = english_from_source("en/ui.ron", r#"{"a.one": "One"}"#).unwrap();
461        let info = r#"(name: "Português do Brasil", made_by: Machine)"#;
462        let pack = pack_from_sources(("l", info), ("u", "[]"), &english, &RULES).unwrap();
463        let packs = BTreeMap::from([(LangCode::new("pt-BR").unwrap(), pack)]);
464        let lang = Lang::new(english, packs);
465        for spelling in ["pt-br", "pt_BR.UTF-8", "PT-BR"] {
466            let said = report(&lang, spelling, &Options::default()).unwrap();
467            assert!(
468                said.starts_with("lang-status pt-BR (Português do Brasil, machine-made)"),
469                "{said}"
470            );
471        }
472    }
473
474    /// The scenes of `files` (path and text), each knowing its file.
475    fn table(files: &[(&str, &str)]) -> DialogueTable {
476        let mut table = DialogueTable::default();
477        for &(path, text) in files {
478            let (parsed, errors) = parse_dlg(path, text);
479            assert!(errors.is_empty(), "{errors:?}");
480            for scene in parsed.into_iter().map(|p| p.scene) {
481                table.files.insert(scene.id.clone(), path.to_owned());
482                table.scenes.insert(scene.id.clone(), scene);
483            }
484        }
485        table
486    }
487
488    /// The English of the lines of `dlg`, by line id.
489    fn lines_of(dlg: &str) -> SourceText {
490        let mut data = SourceText::default();
491        data.add_dialogue(&table(&[("t.dlg", dlg)]));
492        data
493    }
494
495    /// The id of the line of `data` that says `text`.
496    fn id_of(data: &SourceText, text: &str) -> String {
497        let line = data.lines.iter().find(|(_, line)| line.text == text);
498        line.map(|(id, _)| id.clone()).unwrap()
499    }
500
501    /// Rewording an English line orphans its entry, which is paired with
502    /// the new line; an orphan in a scene with nothing left to translate
503    /// has no pair.
504    #[test]
505    fn a_reworded_line_is_an_orphan_paired_with_the_new_line() {
506        let before = lines_of(
507            "@scene gate\n> The gate is shut.\n> Nobody answers.\n@end\n\
508             @scene road\n> Dust.\n@end\n",
509        );
510        // A pack that translates every line of that English.
511        let entries: Vec<String> = before
512            .lines
513            .iter()
514            .map(|(id, line)| {
515                let (source, text) = (&line.text, line.text.to_uppercase());
516                format!("(key: {id:?}, source: {source:?}, text: {text:?})")
517            })
518            .collect();
519        let pack = format!("[{}]", entries.join(","));
520        let lang = |data: SourceText| {
521            let files = [
522                ("en/ui.ron", "{}"),
523                ("xx/lang.ron", r#"(name: "Xx", made_by: Machine)"#),
524                ("xx/ui.ron", "[]"),
525                ("xx/dialogue/t.ron", pack.as_str()),
526            ]
527            .map(|(path, source)| LangFile {
528                path,
529                source: Some(source),
530            });
531            from_files(&files, Some(data), str::to_owned, &RULES).unwrap()
532        };
533        assert_eq!(
534            report(&lang(before.clone()), "xx", &Options::default()).unwrap(),
535            "lang-status xx (Xx, machine-made): 0 missing, 0 stale, 0 orphaned\n\
536             screen text: 0 missing, 0 stale, 0 too long\n\
537             data: 0 missing, 0 stale\n\
538             dialogue: 0 missing, 0 stale, 0 orphaned\n"
539        );
540        // English rewords a line of `gate` and cuts the scene `road`.
541        let after = lines_of("@scene gate\n> The gate is barred.\n> Nobody answers.\n@end\n");
542        let new_id = id_of(&after, "The gate is barred.");
543        assert_eq!(
544            report(&lang(after.clone()), "xx", &Options::default()).unwrap(),
545            format!(
546                "lang-status xx (Xx, machine-made): 1 missing, 0 stale, 2 orphaned\n\
547                 screen text: 0 missing, 0 stale, 0 too long\n\
548                 data: 0 missing, 0 stale\n\
549                 dialogue: 1 missing, 0 stale, 2 orphaned\n\
550                 missing:\n  {new_id}\n    source: \"The gate is barred.\"\n\
551                 orphaned:\n  {gate}\n    was: \"The gate is shut.\"\n    \
552                 text: \"THE GATE IS SHUT.\"\n    \
553                 nearest: {new_id}\n    now: \"The gate is barred.\"\n  \
554                 {road}\n    was: \"Dust.\"\n    text: \"DUST.\"\n    \
555                 nearest: no untranslated line in its scene\n",
556                gate = id_of(&before, "The gate is shut."),
557                road = id_of(&before, "Dust."),
558            )
559        );
560        // `--file` keeps an orphan only if its paired line is in that file.
561        let in_file = |stem: &str| Options {
562            file: Some(stem.to_owned()),
563            ..Options::default()
564        };
565        let report = |stem| report(&lang(after.clone()), "xx", &in_file(stem)).unwrap();
566        assert!(report("t").starts_with(
567            "lang-status xx (Xx, machine-made): 1 missing, 0 stale, 1 orphaned
568"
569        ));
570        assert!(report("t").contains(&format!(
571            "  {}
572    was: \"The gate",
573            id_of(&before, "The gate is shut.")
574        )));
575        assert!(report("other").contains(
576            "0 missing, 0 stale, 0 orphaned
577"
578        ));
579    }
580
581    /// English with two `.dlg` files (a real one and a test one) and some
582    /// data, and a pack that translates all of it but the test text.
583    fn two_files(pack_ui: &str) -> Lang {
584        let table = table(&[
585            (
586                "story/dialogue/ch01.dlg",
587                "@scene gate\n> The gate is shut.\n@end\n",
588            ),
589            ("story/dialogue/test.dlg", "@scene t\n> Test line.\n@end\n"),
590        ]);
591        let mut data = SourceText::default();
592        data.add_dialogue(&table);
593        for (key, text) in [
594            ("names.king", "the King"),
595            ("names.test_guide", "Test Guide"),
596        ] {
597            data.data.insert(key.to_owned(), text.to_owned());
598        }
599        let gate = id_of(&data, "The gate is shut.");
600        let dialogue =
601            format!(r#"[(key: {gate:?}, source: "The gate is shut.", text: "LA PORTE")]"#);
602        let files = [
603            ("en/ui.ron", r#"{"a.one": "One"}"#),
604            ("xx/lang.ron", r#"(name: "Xx", made_by: Machine)"#),
605            ("xx/ui.ron", pack_ui),
606            (
607                "xx/data.ron",
608                r#"[(key: "names.king", source: "the King", text: "le roi")]"#,
609            ),
610            ("xx/dialogue/ch01.ron", dialogue.as_str()),
611        ]
612        .map(|(path, source)| LangFile {
613            path,
614            source: Some(source),
615        });
616        from_files(&files, Some(data), str::to_owned, &RULES).unwrap()
617    }
618
619    const ONE: &str = r#"[(key: "a.one", source: "One", text: "Un")]"#;
620
621    #[test]
622    fn a_pack_that_translates_all_but_the_test_text_lacks_nothing() {
623        let lang = two_files(ONE);
624        assert_eq!(
625            report(&lang, "xx", &Options::default()).unwrap(),
626            "lang-status xx (Xx, machine-made): 0 missing, 0 stale, 0 orphaned; \
627             2 test texts left out\n\
628             screen text: 0 missing, 0 stale, 0 too long\n\
629             data: 0 missing, 0 stale\n\
630             dialogue: 0 missing, 0 stale, 0 orphaned\n"
631        );
632    }
633
634    #[test]
635    fn only_narrows_the_report_to_a_part() {
636        let lang = two_files("[]");
637        let only = |part| Options {
638            only: Some(part),
639            file: None,
640        };
641        let ui = report(&lang, "xx", &only(Part::Screen)).unwrap();
642        assert_eq!(
643            ui,
644            "lang-status xx (Xx, machine-made): 1 missing, 0 stale, 0 orphaned\n\
645             screen text: 1 missing, 0 stale, 0 too long\nmissing:\n  a.one\n    source: \"One\"\n"
646        );
647        let data = report(&lang, "xx", &only(Part::Data)).unwrap();
648        assert!(data.contains("\ndata: 0 missing, 0 stale\n"), "{data}");
649        assert!(!data.contains("screen text"), "{data}");
650        assert!(!data.contains("a.one"), "{data}");
651        let dialogue = report(&lang, "xx", &only(Part::Dialogue)).unwrap();
652        assert!(
653            dialogue.ends_with("dialogue: 0 missing, 0 stale, 0 orphaned\n"),
654            "{dialogue}"
655        );
656        assert!(!dialogue.contains("a.one"), "{dialogue}");
657    }
658
659    #[test]
660    fn file_lists_only_that_dlg_files_lines() {
661        let lang = two_files("[]");
662        let file = |stem: &str| Options {
663            file: Some(stem.to_owned()),
664            ..Options::default()
665        };
666        // `ch01` is fully translated; `test` is left out for this pack.
667        assert_eq!(
668            report(&lang, "xx", &file("ch01")).unwrap(),
669            "lang-status xx (Xx, machine-made): 0 missing, 0 stale, 0 orphaned\n\
670             dialogue: 0 missing, 0 stale, 0 orphaned\n"
671        );
672        assert_eq!(
673            report(&lang, "xx", &file("test")).unwrap(),
674            "lang-status xx (Xx, machine-made): 0 missing, 0 stale, 0 orphaned; \
675             1 test texts left out\n\
676             dialogue: 0 missing, 0 stale, 0 orphaned\n"
677        );
678    }
679
680    /// The test pack's report keeps the test text, and lists a line's
681    /// variants in the order given.
682    #[test]
683    fn the_test_pack_keeps_the_test_text_and_variants_are_listed_in_order() {
684        let before = lines_of("@scene test_s\n> Old words.\n> Kept.\n@end\n");
685        let after = lines_of("@scene test_s\n> New words.\n> Kept.\n@end\n");
686        let old = id_of(&before, "Old words.");
687        let entry = format!(r#"[(key: {old:?}, source: "Old words.", text_f: "F", text_m: "M")]"#);
688        let rules = PackRules {
689            variants: &["m", "f"],
690            ..RULES
691        };
692        let files = [
693            ("en/ui.ron", r#"{"names.test_guide": "Test"}"#),
694            ("test/lang.ron", r#"(name: "TEST", made_by: Machine)"#),
695            ("test/ui.ron", "[]"),
696            ("test/dialogue/t.ron", entry.as_str()),
697        ]
698        .map(|(path, source)| LangFile {
699            path,
700            source: Some(source),
701        });
702        let lang = from_files(&files, Some(after.clone()), str::to_owned, &rules).unwrap();
703        let said = super::report(
704            &lang,
705            &["m", "f"],
706            &CellWidths::NARROW,
707            TEST_PACK,
708            &Options::default(),
709        )
710        .unwrap();
711        assert!(!said.contains("left out"), "{said}");
712        assert!(said.contains("  names.test_guide\n"), "{said}");
713        assert!(
714            said.contains(&format!(
715                "orphaned:\n  {old}\n    was: \"Old words.\"\n    text_m: \"M\"\n    \
716                 text_f: \"F\"\n    nearest: "
717            )),
718            "{said}"
719        );
720    }
721
722    #[test]
723    fn arguments_are_parsed() {
724        let args = |a: &[&str]| a.iter().map(|s| (*s).to_owned()).collect::<Vec<_>>();
725        let (code, options) = parse_args(&args(&["xx", "--only", "data"])).unwrap();
726        assert_eq!((code.as_str(), options.only), ("xx", Some(Part::Data)));
727        let (_, options) = parse_args(&args(&["--file", "ch01.dlg", "xx"])).unwrap();
728        assert_eq!(options.file.as_deref(), Some("ch01"));
729        assert!(parse_args(&args(&[])).is_err());
730        assert!(parse_args(&args(&["xx", "yy"])).is_err());
731        assert!(parse_args(&args(&["xx", "--only", "nope"])).is_err());
732        assert!(parse_args(&args(&["xx", "--only"])).is_err());
733        assert!(parse_args(&args(&["xx", "--file"])).is_err());
734        assert!(parse_args(&args(&["xx", "--bogus"])).is_err());
735        assert_eq!(
736            parse_args(&args(&["--bogus"])).unwrap_err(),
737            "unknown option --bogus"
738        );
739        let (_, options) = parse_args(&args(&["xx", "--only", "ui"])).unwrap();
740        assert_eq!(options.only, Some(Part::Screen));
741        assert!(parse_args(&args(&["xx", "--only", "ui", "--file", "a"])).is_err());
742        assert!(parse_args(&args(&["xx", "--only", "dialogue", "--file", "a"])).is_ok());
743    }
744}