Skip to main content

shuyi_cli/contract/
check.rs

1//! `check [--baked]`: the game loads all its content through every
2//! validator it has, and the answer lists every error found, each with its
3//! file, line and message. With `--baked`, the game's sources are baked
4//! into a pack too, and every asset read from the pack must equal the one
5//! read from its source ([`bake::check_baked`]). Once the content loads
6//! without errors, the game's text is checked against its font atlas
7//! ([`GameTools::font_atlas`], `shuyi_assets::font::undrawn`): a character
8//! the atlas can't draw is an error. It passes when there are no errors.
9//!
10//! `check --target <id>` checks instead that the game fits target `id`,
11//! or every target its `shuyi.ron` lists with `--target all`
12//! ([`super::target`]). The display and who the game is (`shuyi.ron`'s
13//! `app`) need only the project file; the
14//! controls, and a one-bit target's patterns, need the game's tools too,
15//! and without them the answer says they weren't checked. It passes when
16//! no target has an error; warnings don't fail it.
17
18use std::fmt::Write as _;
19use std::path::Path;
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use shuyi_assets::ContentError;
24use shuyi_assets::font;
25use shuyi_assets::target::Color;
26
27use super::codes::Code;
28use super::target::{self, Level, TargetReport};
29use super::{Answer, Op, Problem, bake, codes};
30use crate::app_info;
31use crate::game::{GameTools, code_of};
32use crate::project::{Config, FILE};
33
34/// What `check` checks: everything the game loads (with `baked`, its pack
35/// too), or with `target`, how the game fits a target.
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38// An input: built with `default()` and its fields assigned, it may grow
39// (ADR-0039).
40#[non_exhaustive]
41pub struct CheckInput {
42    /// The target to check the game against, by its profile's id, or
43    /// `all` for every target in the game's `shuyi.ron`. Without it, the
44    /// game's content is checked.
45    #[serde(default)]
46    pub target: Option<String>,
47    /// Whether to bake the game's pack too, and check every asset read
48    /// from it against the one read from its source.
49    #[serde(default)]
50    pub baked: bool,
51}
52
53/// What `check` found.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(deny_unknown_fields)]
56pub struct CheckOutput {
57    /// Every error of the game's content, in the order the game's
58    /// validators found them (none with a target).
59    pub errors: Vec<Problem>,
60    /// Each target checked, with its problems (none without a target).
61    #[serde(default)]
62    pub targets: Vec<TargetReport>,
63}
64
65/// `check`: every error of `game`'s content, and with `baked`, every way
66/// its pack differs from its sources. A target is checked by [`targets`].
67pub fn check(game: &dyn GameTools, input: &CheckInput) -> Answer<CheckOutput> {
68    if input.target.is_some() {
69        return Answer::failed(
70            Op::Check,
71            codes::WRONG_ARGUMENTS,
72            "check --target is checked by check::targets, with the project file",
73        );
74    }
75    let found = game.check().and_then(|mut errors| {
76        let loaded = errors.is_empty();
77        if input.baked {
78            errors.extend(bake::check_baked(game)?);
79        }
80        Ok((errors, loaded))
81    });
82    match found {
83        Ok((mut errors, loaded)) => {
84            let mut atlas_limit = None;
85            if loaded {
86                match undrawn(game) {
87                    Ok(Some(found)) => errors.extend(found),
88                    Ok(None) => atlas_limit = Some(ATLAS_NOT_OFFERED),
89                    Err((code, e)) => return Answer::failed(Op::Check, code, e),
90                }
91            } else {
92                atlas_limit = Some(ATLAS_AFTER_ERRORS);
93            }
94            let errors: Vec<Problem> = errors.iter().map(Problem::from).collect();
95            let mut limits = Vec::new();
96            if errors.iter().any(|e| e.line.is_none()) {
97                limits.push(
98                    "some errors are about a whole file: their validator gives no line".to_owned(),
99                );
100            }
101            limits.extend(atlas_limit.map(str::to_owned));
102            let output = CheckOutput {
103                errors,
104                targets: Vec::new(),
105            };
106            Answer::new(Op::Check, output.errors.is_empty(), output, limits)
107        }
108        Err(e) => Answer::failed(Op::Check, code_of(&e), e),
109    }
110}
111
112/// `check`'s limit when the game's tools don't offer its font atlas.
113const ATLAS_NOT_OFFERED: &str = "the text isn't checked against the font atlas: the game's tools \
114                                 don't offer it (GameTools::font_atlas)";
115
116/// `check`'s limit when the content has errors.
117const ATLAS_AFTER_ERRORS: &str =
118    "the text is checked against the font atlas once the content loads without errors";
119
120/// Every character of `game`'s text its font atlas can't draw
121/// ([`font::undrawn`]); `None` if its tools don't offer the atlas, and the
122/// code and message to fail with if the atlas or the text doesn't load.
123fn undrawn(game: &dyn GameTools) -> Result<Option<Vec<ContentError>>, (Code, String)> {
124    let atlas = match game.font_atlas() {
125        None => return Ok(None),
126        Some(Ok(atlas)) => atlas,
127        Some(Err(e)) => {
128            return Err((
129                code_of(&e),
130                format!("the game's font atlas doesn't load: {e}"),
131            ));
132        }
133    };
134    let lang = game
135        .lang()
136        .map_err(|e| (code_of(&e), format!("the game's text doesn't load: {e}")))?;
137    Ok(Some(font::undrawn(&atlas, &lang.lang)))
138}
139
140/// `check --target`: how the game of `config` (the project at `root`)
141/// fits the targets `input` asks for; its controls and a one-bit target's
142/// patterns too with its tools, `game`.
143pub fn targets(
144    config: &Config,
145    root: &Path,
146    input: &CheckInput,
147    game: Option<&dyn GameTools>,
148) -> Answer<CheckOutput> {
149    let Some(asked) = &input.target else {
150        return Answer::failed(Op::Check, codes::WRONG_ARGUMENTS, "no target asked for");
151    };
152    let settings = match target::asked(config, asked) {
153        Ok(settings) => settings,
154        Err(e) => return Answer::failed(Op::Check, codes::TARGET, e),
155    };
156    let mut reports = Vec::new();
157    let mut limits = Vec::new();
158    let controls = match game.map(GameTools::controls) {
159        Some(Some(Ok(controls))) => Some(controls),
160        Some(Some(Err(e))) => {
161            let error = format!("the game's controls don't load: {e}");
162            return Answer::failed(Op::Check, code_of(&e), error);
163        }
164        Some(None) => {
165            limits.push(
166                "the controls aren't checked: the game's tools don't offer them \
167                 (GameTools::controls)"
168                    .to_owned(),
169            );
170            None
171        }
172        None => {
173            limits.push(format!(
174                "the controls aren't checked: they need the game's tools, and {FILE} names no \
175                 game tool program (`game`)"
176            ));
177            None
178        }
179    };
180    let app = config
181        .app
182        .as_ref()
183        .map(|part| app_info::problems(part, root, config.command()));
184    for (id, setting) in settings {
185        let profile = match target::profile(config, root, id) {
186            Ok(profile) => profile,
187            Err((code, why)) => return Answer::failed(Op::Check, code, why),
188        };
189        if profile.profile.display.sizes.is_empty() {
190            limits.push(format!(
191                "{id}'s display takes any size: its frame fits, and its text is checked at scale 1"
192            ));
193        }
194        let mut problems = target::check_target(id, setting, &profile.profile);
195        if let Some(controls) = &controls {
196            problems.extend(target::controls(id, controls.fit(&profile.profile)));
197        }
198        if profile.profile.display.color == Color::OneBit {
199            match game.map(|game| game.one_bit(id)) {
200                Some(Some(Ok(skin))) => problems.extend(target::one_bit(&skin)),
201                Some(Some(Err(e))) => {
202                    return Answer::failed(
203                        Op::Check,
204                        code_of(&e),
205                        format!("the game's one-bit skin for {id} doesn't load: {e}"),
206                    );
207                }
208                Some(None) => limits.push(format!(
209                    "{id}'s one-bit patterns aren't checked: the game's tools don't offer them \
210                     (GameTools::one_bit)"
211                )),
212                None => limits.push(format!(
213                    "{id}'s one-bit patterns aren't checked: they need the game's tools, and \
214                     {FILE} names no game tool program (`game`)"
215                )),
216            }
217        }
218        if let Some(app) = &app {
219            problems.extend(target::app(app.clone()));
220        }
221        reports.push(TargetReport {
222            target: id.to_owned(),
223            profile: profile.file.clone(),
224            problems,
225        });
226    }
227    if app.is_none() {
228        limits.push(format!("the identity isn't checked: {FILE} has no `app`"));
229    }
230    limits.push(
231        "only the frame's fit, the text's size, the controls, one-bit patterns and the identity \
232         are checked yet"
233            .to_owned(),
234    );
235    let ok = !reports.iter().any(TargetReport::failed);
236    let output = CheckOutput {
237        errors: Vec::new(),
238        targets: reports,
239    };
240    Answer::new(Op::Check, ok, output, limits)
241}
242
243/// `check`'s arguments (`--json` taken out): none, `--baked`, or
244/// `--target <id>`.
245pub fn parse_args(args: &[String]) -> Result<CheckInput, String> {
246    match args {
247        [] => Ok(CheckInput::default()),
248        [flag] if flag == "--baked" => Ok(CheckInput {
249            baked: true,
250            ..CheckInput::default()
251        }),
252        [flag, target] if flag == "--target" => Ok(CheckInput {
253            target: Some(target.clone()),
254            ..CheckInput::default()
255        }),
256        _ => Err("no arguments, --baked, or --target <id | all>".to_owned()),
257    }
258}
259
260/// `check`'s usage, with `command` as the tool's name.
261pub fn usage(command: &str) -> String {
262    format!("usage: {command} check [--baked | --target <id | all>] [--json]")
263}
264
265/// What `check --target` found, for people: a line a target, then its
266/// problems.
267fn targets_text(reports: &[TargetReport]) -> String {
268    let mut out = String::new();
269    for report in reports {
270        let count = |level| report.problems.iter().filter(|p| p.level == level).count();
271        let (errors, warnings) = (count(Level::Error), count(Level::Warning));
272        // Writing to a `String` can't fail.
273        let _ = if errors + warnings == 0 {
274            writeln!(
275                out,
276                "check --target {}: OK ({})",
277                report.target, report.profile
278            )
279        } else {
280            writeln!(
281                out,
282                "check --target {}: {errors} error(s), {warnings} warning(s) ({})",
283                report.target, report.profile
284            )
285        };
286        for problem in &report.problems {
287            let level = match problem.level {
288                Level::Error => "error",
289                Level::Warning => "warning",
290            };
291            let _ = writeln!(
292                out,
293                "  {level}: {}: {}: {}",
294                report.target, problem.setting, problem.message
295            );
296        }
297    }
298    out
299}
300
301/// What `check` found, for people.
302pub fn text(output: &CheckOutput) -> String {
303    if !output.targets.is_empty() {
304        return targets_text(&output.targets);
305    }
306    if output.errors.is_empty() {
307        return "check: OK\n".to_owned();
308    }
309    let mut out = format!("check: {} error(s)\n", output.errors.len());
310    for error in &output.errors {
311        // Writing to a `String` can't fail.
312        let _ = writeln!(out, "  {error}");
313    }
314    out.push_str(&codes::help_lines(&output.errors));
315    out
316}
317
318#[cfg(test)]
319mod tests {
320    use shuyi_assets::font::FontAtlasDef;
321    use shuyi_assets::lang::Lang;
322    use shuyi_assets::onebit::{Pattern, PatternMapDef};
323    use shuyi_assets::palette::PaletteDef;
324
325    use super::*;
326    use crate::contract::fake::Fake;
327    use crate::contract::target::TargetCheck;
328    use crate::game::OneBitSkin;
329
330    #[test]
331    fn check_passes_with_no_error_and_lists_each_with_where() {
332        let answer = check(&Fake::default(), &CheckInput::default());
333        assert!(answer.ok);
334        assert_eq!(answer.op, Op::Check);
335        assert!(answer.limits.is_empty(), "{:?}", answer.limits);
336        assert_eq!(text(answer.result.as_ref().unwrap()), "check: OK\n");
337        let game = Fake {
338            check: Ok(vec![
339                ContentError::new("a.ron", "bad").at(2, Some(5)),
340                ContentError::new("b.map", "missing"),
341            ]),
342            ..Fake::default()
343        };
344        let answer = check(&game, &CheckInput::default());
345        assert!(!answer.ok);
346        assert_eq!(answer.exit_code(), 1);
347        let out = answer.result.as_ref().unwrap();
348        assert_eq!(out.errors[0].line, Some(2));
349        assert_eq!(
350            answer.limits,
351            [
352                "some errors are about a whole file: their validator gives no line",
353                ATLAS_AFTER_ERRORS
354            ]
355        );
356        assert_eq!(
357            text(out),
358            "check: 2 error(s)\n  a.ron:2:5: bad\n  b.map: missing\n"
359        );
360        // Errors that all have a line need no limit of their own.
361        let lined = Fake {
362            check: Ok(vec![ContentError::new("a.ron", "bad").at(1, None)]),
363            ..Fake::default()
364        };
365        let got = check(&lined, &CheckInput::default()).limits;
366        assert_eq!(
367            got,
368            ["the text is checked against the font atlas once the content loads without errors"]
369        );
370    }
371
372    /// English's `menu.title`, and the pack `ru`, whose `menu.title` is
373    /// written `title`.
374    fn russian(title: &str) -> Lang {
375        one_pack("ru", r#"(name: "Russkij", made_by: Machine)"#, title)
376    }
377
378    /// English's `menu.title`, and the pack `code` described by `info` (its
379    /// `lang.ron`), whose `menu.title` is written `title`.
380    fn one_pack(code: &str, info: &str, title: &str) -> Lang {
381        use shuyi_assets::lang::{LangFile, PackRules, from_files};
382        let ui = format!(r#"[(key: "menu.title", source: "Start", text: "{title}")]"#);
383        let (info_path, ui_path) = (format!("{code}/lang.ron"), format!("{code}/ui.ron"));
384        let files = [
385            ("en/ui.ron", r#"{"menu.title": "Start"}"#),
386            (info_path.as_str(), info),
387            (ui_path.as_str(), ui.as_str()),
388        ]
389        .map(|(path, source)| LangFile {
390            path,
391            source: Some(source),
392        });
393        let rules = PackRules {
394            data_problem: |_, _| None,
395            tokens: &shuyi_assets::dialogue::NoTokens,
396            limits: shuyi_assets::dialogue::DialogueLimits {
397                box_width: 40,
398                box_lines: 3,
399                max_text: 200,
400                max_option: 30,
401                min_options: 2,
402                max_options: 4,
403                max_reaction_texts: 3,
404            },
405            widths: &shuyi_assets::font::CellWidths::NARROW,
406            variants: &[],
407        };
408        from_files(&files, None, str::to_owned, &rules).unwrap()
409    }
410
411    /// An atlas of ASCII's printable characters, and `extra`.
412    fn ascii_and(extra: &str) -> FontAtlasDef {
413        FontAtlasDef {
414            cell_w: 8,
415            cell_h: 16,
416            columns: 16,
417            glyphs: (' '..='~').chain(extra.chars()).zip(0..).collect(),
418            ..FontAtlasDef::default()
419        }
420    }
421
422    #[test]
423    fn check_counts_a_wide_glyph_as_drawn_and_fails_on_one_the_atlas_lacks() {
424        let game = Fake {
425            lang: Ok(russian("日a")),
426            font_atlas: Some(Ok(ascii_and(""))),
427            ..Fake::default()
428        };
429        let answer = check(&game, &CheckInput::default());
430        assert!(!answer.ok);
431        let errors = &answer.result.as_ref().unwrap().errors;
432        assert_eq!(errors.len(), 1, "{errors:?}");
433        assert!(
434            errors[0]
435                .message
436                .starts_with("'日' (U+65E5) isn't in the font atlas")
437        );
438        let mut atlas = ascii_and("");
439        atlas.wide.insert('日', 96);
440        let drawn = Fake {
441            font_atlas: Some(Ok(atlas)),
442            ..game
443        };
444        let answer = check(&drawn, &CheckInput::default());
445        assert!(answer.ok, "{:?}", answer.result);
446    }
447
448    #[test]
449    fn check_fails_on_a_wide_character_the_set_a_pack_names_lacks() {
450        // 简 is in the atlas, in the Chinese set, but not in the Japanese
451        // set the pack draws from.
452        let mut atlas = ascii_and("");
453        atlas.wide_sets = [
454            ("ja".to_owned(), [('直', 96)].into()),
455            ("zh-Hans".to_owned(), [('直', 98), ('简', 100)].into()),
456        ]
457        .into();
458        let info =
459            |set: &str| format!(r#"(name: "Pack", made_by: Machine, wide_font: Some("{set}"))"#);
460        let game = |set: &str| Fake {
461            lang: Ok(one_pack("ja", &info(set), "直简")),
462            font_atlas: Some(Ok(atlas.clone())),
463            ..Fake::default()
464        };
465        let answer = check(&game("ja"), &CheckInput::default());
466        assert!(!answer.ok);
467        let errors = &answer.result.as_ref().unwrap().errors;
468        assert_eq!(errors.len(), 1, "{errors:?}");
469        assert_eq!(errors[0].file, "ja/ui.ron");
470        assert!(
471            errors[0]
472                .message
473                .starts_with("'简' (U+7B80) isn't in the font atlas's wide set \"ja\""),
474            "{errors:?}"
475        );
476        // Drawn from the set that has both, it passes.
477        let answer = check(&game("zh-Hans"), &CheckInput::default());
478        assert!(answer.ok, "{:?}", answer.result);
479    }
480
481    /// The errors of a game's tips, `title` the one tip's title, whose
482    /// loader measures them with `atlas`'s widths, as a game's does when
483    /// its atlas loads before its text.
484    fn tip_errors(title: &str, atlas: &FontAtlasDef) -> Vec<ContentError> {
485        use shuyi_assets::font::CellWidths;
486        use shuyi_assets::tip::{TipLimits, TipRules, from_source};
487        let source =
488            format!(r#"(tips: [(id: "a", trigger: "jumped", title: {title:?}, text: "x")])"#);
489        let widths = CellWidths::of(atlas);
490        let rules = TipRules {
491            limits: TipLimits {
492                text_lines: 1,
493                line_chars: 10,
494                title_chars: 6,
495                how_lines: 0,
496            },
497            widths: &widths,
498            placeholder: &|_| true,
499            face_problem: &|_| None,
500            default_face: "calm",
501        };
502        from_source::<String>("tips.ron", &source, &rules)
503            .err()
504            .unwrap_or_default()
505    }
506
507    #[test]
508    fn check_measures_the_text_with_the_font_atlass_wide_glyphs() {
509        // Four characters, under the title's 6; but 8 cells drawn wide.
510        let title = "日本語だ";
511        let narrow = ascii_and(title);
512        let mut wide = ascii_and("");
513        wide.wide = title.chars().zip(96..).collect();
514        for (atlas, ok) in [(narrow, true), (wide, false)] {
515            let game = Fake {
516                check: Ok(tip_errors(title, &atlas)),
517                lang: Ok(russian(title)),
518                font_atlas: Some(Ok(atlas)),
519                ..Fake::default()
520            };
521            let answer = check(&game, &CheckInput::default());
522            assert_eq!(answer.ok, ok, "{:?}", answer.result);
523            let errors = &answer.result.as_ref().unwrap().errors;
524            let messages: Vec<&str> = errors.iter().map(|e| e.message.as_str()).collect();
525            let over = ["tip \"a\": the title must be 1 to 6 characters"];
526            assert_eq!(messages, if ok { &[][..] } else { &over[..] });
527        }
528    }
529
530    #[test]
531    fn check_fails_on_a_character_the_font_atlas_cant_draw() {
532        let game = Fake {
533            lang: Ok(russian("Жa")),
534            font_atlas: Some(Ok(ascii_and(""))),
535            ..Fake::default()
536        };
537        let answer = check(&game, &CheckInput::default());
538        assert!(!answer.ok);
539        let errors = &answer.result.as_ref().unwrap().errors;
540        assert_eq!(errors.len(), 1, "{errors:?}");
541        assert_eq!(errors[0].file, "ru/ui.ron");
542        assert_eq!(errors[0].line, None);
543        assert!(
544            errors[0]
545                .message
546                .starts_with("'Ж' (U+0416) isn't in the font atlas (menu.title)"),
547            "{}",
548            errors[0].message
549        );
550        assert_eq!(
551            answer.limits,
552            ["some errors are about a whole file: their validator gives no line"]
553        );
554        // With the glyph, it passes.
555        let drawn = Fake {
556            font_atlas: Some(Ok(ascii_and("Ж"))),
557            ..game
558        };
559        let answer = check(&drawn, &CheckInput::default());
560        assert!(answer.ok, "{:?}", answer.result);
561        assert!(answer.limits.is_empty(), "{:?}", answer.limits);
562    }
563
564    #[test]
565    fn a_game_that_doesnt_offer_its_atlas_passes_and_says_so() {
566        let game = Fake {
567            lang: Ok(russian("Жa")),
568            font_atlas: None,
569            ..Fake::default()
570        };
571        let answer = check(&game, &CheckInput::default());
572        assert!(answer.ok);
573        assert_eq!(
574            answer.limits,
575            [
576                "the text isn't checked against the font atlas: the game's tools don't offer it \
577                 (GameTools::font_atlas)"
578            ]
579        );
580    }
581
582    #[test]
583    fn an_atlas_or_a_text_that_doesnt_load_fails_the_check() {
584        let atlas = Fake {
585            font_atlas: Some(Err("font/atlas.ron: bad".to_owned())),
586            ..Fake::default()
587        };
588        let answer = check(&atlas, &CheckInput::default());
589        assert_eq!(
590            answer.error.as_deref(),
591            Some("the game's font atlas doesn't load: font/atlas.ron: bad")
592        );
593        let text = Fake {
594            lang: Err("lang/en/ui.ron: bad".to_owned()),
595            ..Fake::default()
596        };
597        let answer = check(&text, &CheckInput::default());
598        assert_eq!(
599            answer.error.as_deref(),
600            Some("the game's text doesn't load: lang/en/ui.ron: bad")
601        );
602        // With errors in the content, neither is asked for.
603        let both = Fake {
604            check: Ok(vec![ContentError::new("a.ron", "bad").at(1, None)]),
605            ..atlas
606        };
607        assert_eq!(check(&both, &CheckInput::default()).error, None);
608    }
609
610    #[test]
611    fn a_game_that_cant_check_says_why() {
612        let game = Fake {
613            check: Err("no data".to_owned()),
614            ..Fake::default()
615        };
616        let answer = check(&game, &CheckInput::default());
617        assert_eq!(answer.error.as_deref(), Some("no data"));
618    }
619
620    fn on(target: &str) -> CheckInput {
621        CheckInput {
622            target: Some(target.to_owned()),
623            ..CheckInput::default()
624        }
625    }
626
627    #[test]
628    fn check_takes_no_argument_baked_or_a_target() {
629        let args =
630            |items: &[&str]| -> Vec<String> { items.iter().map(|s| (*s).to_owned()).collect() };
631        assert_eq!(parse_args(&[]), Ok(CheckInput::default()));
632        assert_eq!(parse_args(&args(&["--target", "web"])), Ok(on("web")));
633        let baked = CheckInput {
634            baked: true,
635            ..CheckInput::default()
636        };
637        assert_eq!(parse_args(&args(&["--baked"])), Ok(baked));
638        assert!(parse_args(&args(&["--target"])).is_err());
639        assert!(parse_args(&args(&["web"])).is_err());
640        assert!(parse_args(&args(&["--targets", "web"])).is_err());
641        assert!(parse_args(&args(&["--baked", "--target", "web"])).is_err());
642        assert_eq!(
643            usage("shuyi"),
644            "usage: shuyi check [--baked | --target <id | all>] [--json]"
645        );
646        // Targets are `targets`'s.
647        let answer = check(&Fake::default(), &on("web"));
648        assert!(answer.error.unwrap().contains("check::targets"));
649    }
650
651    #[test]
652    fn targets_are_checked_one_or_all_and_warnings_dont_fail() {
653        let config = crate::project::Config::parse(
654            r#"(targets: {
655    "playdate": (frame: (800, 512), scale: integer, text_px: 16),
656    "steam-deck": (frame: (800, 512), scale: integer, text_px: 10),
657    "desktop": (frame: (800, 512), scale: integer, text_px: 16),
658})"#,
659        )
660        .unwrap();
661        let root = Path::new(".");
662        let deck = targets(&config, root, &on("steam-deck"), None);
663        assert!(deck.ok, "a warning passes");
664        let out = deck.result.as_ref().unwrap();
665        assert_eq!(out.targets.len(), 1);
666        assert_eq!(out.targets[0].profile, "targets/steam-deck.ron");
667        assert_eq!(
668            text(out),
669            "check --target steam-deck: 0 error(s), 1 warning(s) (targets/steam-deck.ron)\n  \
670             warning: steam-deck: targets.\"steam-deck\".text_px: text 10 px at scale 1 is 10 px \
671             on 1280 × 800, below the 12 px the target recommends\n"
672        );
673        assert_eq!(
674            deck.limits,
675            [
676                "the controls aren't checked: they need the game's tools, and shuyi.ron names no \
677                 game tool program (`game`)",
678                "the identity isn't checked: shuyi.ron has no `app`",
679                "only the frame's fit, the text's size, the controls, one-bit patterns and the \
680                 identity are checked yet"
681            ]
682        );
683        let all = targets(&config, root, &on("all"), None);
684        assert!(!all.ok, "the Playdate fails");
685        let out = all.result.as_ref().unwrap();
686        let ids: Vec<&str> = out.targets.iter().map(|r| r.target.as_str()).collect();
687        assert_eq!(ids, ["desktop", "playdate", "steam-deck"]);
688        assert!(
689            text(out).starts_with(
690                "check --target desktop: OK (targets/desktop.ron)\ncheck --target playdate: 1 \
691                 error(s), 0 warning(s)"
692            ),
693            "{}",
694            text(out)
695        );
696        assert_eq!(all.limits.len(), 5, "desktop takes any size");
697        assert_eq!(
698            all.limits[2],
699            "playdate's one-bit patterns aren't checked: they need the game's tools, and \
700             shuyi.ron names no game tool program (`game`)"
701        );
702        // A target not listed, or listed with no profile.
703        assert!(targets(&config, root, &on("web"), None).error.is_some());
704        assert!(
705            targets(&config, root, &CheckInput::default(), None)
706                .error
707                .is_some()
708        );
709        let unknown = crate::project::Config::parse(
710            r#"(targets: {"moon": (frame: (1, 1), scale: fit, text_px: 9)})"#,
711        )
712        .unwrap();
713        let answer = targets(&unknown, root, &on("moon"), None);
714        assert!(
715            answer
716                .error
717                .unwrap()
718                .starts_with("no target profile \"moon\"; the profiles are android, desktop")
719        );
720        let broken = crate::project::Config {
721            target_profiles: Some("no-such-folder".to_owned()),
722            ..config
723        };
724        let answer = targets(&broken, root, &on("all"), None);
725        assert!(
726            answer
727                .error
728                .unwrap()
729                .starts_with("the target profiles don't load: no-such-folder: ")
730        );
731    }
732
733    #[test]
734    fn the_app_part_is_checked_on_every_target() {
735        let with = |app: &str| {
736            crate::project::Config::parse(&format!(
737                r#"(app: {app}, targets: {{
738    "desktop": (frame: (400, 240), scale: integer, text_px: 16),
739    "web": (frame: (400, 240), scale: integer, text_px: 16),
740}})"#
741            ))
742            .unwrap()
743        };
744        let root = Path::new(".");
745        let bad = with(r#"(name: "Moon Garden", id: "moon")"#);
746        let answer = targets(&bad, root, &on("all"), None);
747        assert!(!answer.ok, "a bad id fails");
748        for report in &answer.result.as_ref().unwrap().targets {
749            let found: Vec<(Level, TargetCheck, &str)> = report
750                .problems
751                .iter()
752                .map(|p| (p.level, p.check, p.setting.as_str()))
753                .collect();
754            assert_eq!(found, [(Level::Error, TargetCheck::App, "app.id")]);
755        }
756        assert!(!answer.limits.iter().any(|l| l.contains("identity isn't")));
757        let placeholder = with(r#"(name: "Moon Garden", id: "com.example.moon-garden")"#);
758        let answer = targets(&placeholder, root, &on("web"), None);
759        assert!(answer.ok, "the placeholder is a warning");
760        let out = answer.result.as_ref().unwrap();
761        assert_eq!(out.targets[0].problems[0].level, Level::Warning);
762        assert!(
763            text(out)
764                .contains("  warning: web: app.id: \"com.example.moon-garden\" is a placeholder"),
765            "{}",
766            text(out)
767        );
768    }
769
770    #[test]
771    fn with_the_games_tools_its_controls_are_checked_on_each_target() {
772        let config = crate::project::Config::parse(
773            r#"(targets: {
774    "playdate": (frame: (400, 240), scale: integer, text_px: 16),
775    "desktop": (frame: (400, 240), scale: integer, text_px: 16),
776})"#,
777        )
778        .unwrap();
779        let root = Path::new(".");
780        let game = Fake {
781            controls: Some(Ok(())),
782            ..Fake::default()
783        };
784        let all = targets(&config, root, &on("all"), Some(&game));
785        assert!(!all.ok, "the Playdate has no table");
786        let out = all.result.as_ref().unwrap();
787        let desktop = &out.targets[0];
788        assert!(!desktop.failed(), "a keyboard reaches everything");
789        assert_eq!(desktop.problems.len(), 1);
790        let warning = &desktop.problems[0];
791        assert_eq!(
792            (warning.level, warning.check, warning.setting.as_str()),
793            (Level::Warning, TargetCheck::Controls, "screen \"title\"")
794        );
795        assert_eq!(warning.screen.as_deref(), Some("title"));
796        let playdate = &out.targets[1];
797        assert_eq!(playdate.problems.len(), 2);
798        let error = &playdate.problems[1];
799        assert_eq!(
800            (error.level, error.setting.as_str()),
801            (Level::Error, "keymap targets.\"playdate\"")
802        );
803        assert!(
804            text(out).contains(
805                "  error: playdate: keymap targets.\"playdate\": the keymap file has no buttons \
806                 for playdate, which has no keyboard"
807            ),
808            "{}",
809            text(out)
810        );
811        assert!(!all.limits.iter().any(|l| l.contains("controls aren't")));
812        // A game whose tools don't offer them: said, and the display alone.
813        let answer = targets(&config, root, &on("playdate"), Some(&Fake::default()));
814        assert!(answer.ok);
815        assert_eq!(
816            answer.limits[0],
817            "the controls aren't checked: the game's tools don't offer them (GameTools::controls)"
818        );
819        // A game whose controls don't load fails.
820        let broken = Fake {
821            controls: Some(Err("keymap.ron: bad".to_owned())),
822            ..Fake::default()
823        };
824        let answer = targets(&config, root, &on("playdate"), Some(&broken));
825        assert!(!answer.ok);
826        assert_eq!(
827            answer.error.as_deref(),
828            Some("the game's controls don't load: keymap.ron: bad")
829        );
830    }
831
832    /// A skin of three teams, two of them on one pattern, and text in a
833    /// dither.
834    fn teams() -> OneBitSkin {
835        let palette = PaletteDef::from_source(
836            "files/palette.ron",
837            r##"{"text": "#ffffff", "team.player": "#0000ff", "team.enemy": "#ff0000"}"##,
838            &[],
839        )
840        .unwrap();
841        let map = PatternMapDef::from_source(
842            "files/palette@onebit.ron",
843            r#"(
844                roles: {
845                    "text": (fg: Dither75, bg: Black),
846                    "team.player": (fg: Dither50),
847                    "team.enemy": (fg: Dither50),
848                },
849                distinct: [["team.player", "team.enemy"]],
850                text: ["text"],
851            )"#,
852        )
853        .unwrap();
854        OneBitSkin {
855            palette_file: "files/palette.ron".to_owned(),
856            palette,
857            map_file: "files/palette@onebit.ron".to_owned(),
858            map,
859        }
860    }
861
862    #[test]
863    fn on_a_one_bit_target_the_roles_that_tell_things_apart_must_stay_distinct() {
864        let config = crate::project::Config::parse(
865            r#"(targets: {
866    "playdate": (frame: (400, 240), scale: integer, text_px: 16),
867    "desktop": (frame: (400, 240), scale: integer, text_px: 16),
868})"#,
869        )
870        .unwrap();
871        let root = Path::new(".");
872        let game = Fake {
873            one_bit: Some(Ok(teams())),
874            ..Fake::default()
875        };
876        let all = targets(&config, root, &on("all"), Some(&game));
877        assert!(!all.ok, "two teams look alike on the Playdate");
878        let out = all.result.as_ref().unwrap();
879        assert!(out.targets[0].problems.is_empty(), "the desktop has colour");
880        let playdate = &out.targets[1].problems;
881        assert_eq!(playdate.len(), 2, "{playdate:?}");
882        let distinct = &playdate[0];
883        assert_eq!(
884            (distinct.level, distinct.check, distinct.setting.as_str()),
885            (
886                Level::Error,
887                TargetCheck::OneBit,
888                "files/palette@onebit.ron"
889            )
890        );
891        assert_eq!(distinct.roles, ["team.player", "team.enemy"]);
892        assert_eq!(
893            (playdate[1].level, playdate[1].roles.as_slice()),
894            (Level::Warning, ["text".to_owned()].as_slice())
895        );
896        assert!(
897            text(out).contains(
898                "  error: playdate: files/palette@onebit.ron: roles \"team.player\" and \
899                 \"team.enemy\" must stay distinct, but their foregrounds look the same in one \
900                 bit (both Dither50)"
901            ),
902            "{}",
903            text(out)
904        );
905        assert!(
906            !all.limits
907                .iter()
908                .any(|l| l.contains("one-bit patterns aren't"))
909        );
910        // Kept apart, it passes; the text's warning doesn't fail it.
911        let mut apart = teams();
912        apart.map.roles.get_mut("team.player").unwrap().fg = Pattern::WHITE;
913        let game = Fake {
914            one_bit: Some(Ok(apart)),
915            ..Fake::default()
916        };
917        assert!(targets(&config, root, &on("playdate"), Some(&game)).ok);
918        // A game whose tools don't offer them: said.
919        let answer = targets(&config, root, &on("playdate"), Some(&Fake::default()));
920        assert!(answer.ok);
921        assert_eq!(
922            answer.limits[1],
923            "playdate's one-bit patterns aren't checked: the game's tools don't offer them \
924             (GameTools::one_bit)"
925        );
926        // A skin that doesn't load fails.
927        let broken = Fake {
928            one_bit: Some(Err("palette@onebit.ron: bad".to_owned())),
929            ..Fake::default()
930        };
931        let answer = targets(&config, root, &on("playdate"), Some(&broken));
932        assert_eq!(
933            answer.error.as_deref(),
934            Some("the game's one-bit skin for playdate doesn't load: palette@onebit.ron: bad")
935        );
936        // The desktop never asks.
937        assert!(targets(&config, root, &on("desktop"), Some(&broken)).ok);
938    }
939}