1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38#[non_exhaustive]
41pub struct CheckInput {
42 #[serde(default)]
46 pub target: Option<String>,
47 #[serde(default)]
50 pub baked: bool,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55#[serde(deny_unknown_fields)]
56pub struct CheckOutput {
57 pub errors: Vec<Problem>,
60 #[serde(default)]
62 pub targets: Vec<TargetReport>,
63}
64
65pub 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
112const 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
116const ATLAS_AFTER_ERRORS: &str =
118 "the text is checked against the font atlas once the content loads without errors";
119
120fn 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
140pub 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
243pub 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
260pub fn usage(command: &str) -> String {
262 format!("usage: {command} check [--baked | --target <id | all>] [--json]")
263}
264
265fn 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 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
301pub 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 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 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 fn russian(title: &str) -> Lang {
375 one_pack("ru", r#"(name: "Russkij", made_by: Machine)"#, title)
376 }
377
378 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 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 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 let answer = check(&game("zh-Hans"), &CheckInput::default());
478 assert!(answer.ok, "{:?}", answer.result);
479 }
480
481 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 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 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 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 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 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 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 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 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 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 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 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 assert!(targets(&config, root, &on("desktop"), Some(&broken)).ok);
938 }
939}