use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
fn spritebox(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_spritebox"))
.args(args)
.output()
.expect("the binary runs")
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("spritebox-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn write(dir: &Path, name: &str, text: &str) -> String {
let path = dir.join(name);
std::fs::write(&path, text).unwrap();
path.display().to_string()
}
const COIN: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/coin.spritebox");
const BAD: &str = "palette p\n k #000\nsprite s 2x1 palette=p\n kx\n";
#[test]
fn check_valid_file() {
let out = spritebox(&["check", COIN]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout(&out).ends_with(": ok\n"), "{}", stdout(&out));
let out = spritebox(&["check", COIN, "--json"]);
assert_eq!(out.status.code(), Some(0));
let json = stdout(&out);
assert!(json.starts_with("{\"version\":1,\"file\":"), "{json}");
assert!(
json.contains("\"ok\":true,\"diagnostics\":[],\"written\":[]}"),
"{json}"
);
}
#[test]
fn check_invalid_file() {
let dir = temp_dir("invalid");
let file = write(&dir, "bad.spritebox", BAD);
let out = spritebox(&["check", &file]);
assert_eq!(out.status.code(), Some(1));
let text = stderr(&out);
assert!(
text.contains(&format!(
"{file}:2:5: error[invalid_argument]: Color `#000`"
)),
"{text}"
);
assert!(
text.contains(&format!("{file}:4:4: error[unknown_name]: Key `x`")),
"{text}"
);
assert!(text.ends_with(": 2 errors\n"), "{text}");
let out = spritebox(&["check", "--json", &file]);
assert_eq!(out.status.code(), Some(1));
let json = stdout(&out);
assert!(json.contains("\"ok\":false"), "{json}");
assert!(json.contains(
"{\"code\":\"invalid_argument\",\"severity\":\"error\",\"line\":2,\"column\":5,\"message\":\"Color `#000` must be `#rrggbb`, `#rrggbbaa` or `transparent`\"}"
), "{json}");
assert!(
json.contains("{\"code\":\"unknown_name\",\"severity\":\"error\",\"line\":4,\"column\":4,"),
"{json}"
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn check_reads_standard_input_and_rejects_bad_utf8() {
let mut child = Command::new(env!("CARGO_BIN_EXE_spritebox"))
.args(["check", "-", "--json"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(b"palette p\n k #000000\n \xff #000000\n")
.unwrap();
let out = child.wait_with_output().unwrap();
assert_eq!(out.status.code(), Some(1));
let json = stdout(&out);
assert!(json.contains("\"file\":\"<stdin>\""), "{json}");
assert!(json.contains("{\"code\":\"syntax\",\"severity\":\"error\",\"line\":3,\"column\":3,\"message\":\"The text is not valid UTF-8\"}"), "{json}");
}
#[test]
fn render_writes_files() {
let dir = temp_dir("render");
let out = spritebox(&[
"render",
COIN,
"--out",
dir.to_str().unwrap(),
"--scale",
"2",
"--padding",
"2",
"--no-extrude",
"--json",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
let json = stdout(&out);
for name in ["atlas.png", "spritebox.json", "preview.png"] {
assert!(
json.contains(&format!("{:?}", dir.join(name).display().to_string())),
"{json}"
);
}
let doc = spritebox::Document::parse(&std::fs::read_to_string(COIN).unwrap()).unwrap();
let options = spritebox::ExportOptions {
atlas: spritebox::AtlasOptions {
padding: 2,
extrude: false,
..Default::default()
},
};
let export = doc.export(&options).unwrap();
assert_eq!(
spritebox::png::read_file(dir.join("atlas.png")).unwrap(),
export.atlas.image
);
assert_eq!(
std::fs::read_to_string(dir.join("spritebox.json")).unwrap(),
export.to_json()
);
let preview = doc
.preview(&spritebox::PreviewOptions {
scale: 2,
..Default::default()
})
.unwrap();
assert_eq!(
spritebox::png::read_file(dir.join("preview.png")).unwrap(),
preview
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn render_writes_tilesets() {
let dir = temp_dir("render-tiles");
let tiles = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/tiles.spritebox");
let out = spritebox(&["render", tiles, "--out", dir.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
let doc = spritebox::Document::parse(&std::fs::read_to_string(tiles).unwrap()).unwrap();
let export = doc.export(&spritebox::ExportOptions::default()).unwrap();
assert_eq!(
spritebox::png::read_file(dir.join("tileset-ground.png")).unwrap(),
export.tilesets[0].image
);
let text = stdout(&out);
let written: Vec<&str> = text
.lines()
.map(|l| l.rsplit(['/', '\\']).next().unwrap())
.collect();
assert_eq!(
written,
[
"atlas.png",
"tileset-ground.png",
"spritebox.json",
"preview.png"
]
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn font_writes_truetype() {
let dir = temp_dir("font");
let font = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/font.spritebox");
let path = dir.join("nested/hud.ttf");
let out = spritebox(&[
"font",
font,
"--out",
path.to_str().unwrap(),
"--family",
"HUD Pixel",
"--json",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
let doc = spritebox::Document::parse(&std::fs::read_to_string(font).unwrap()).unwrap();
let expected = doc
.font("hud")
.unwrap()
.to_ttf(&spritebox::TtfOptions {
family: Some("HUD Pixel".into()),
..Default::default()
})
.unwrap();
assert_eq!(std::fs::read(&path).unwrap(), expected);
assert!(stdout(&out).contains("hud.ttf"));
let two = write(&dir, "two.spritebox", "font a height=1\nfont b height=1\n");
let ttf = dir.join("x.ttf");
let out = spritebox(&["font", &two, "--out", ttf.to_str().unwrap(), "--json"]);
assert_eq!(out.status.code(), Some(2));
assert!(
stdout(&out).contains("choose one with --font"),
"{}",
stdout(&out)
);
let out = spritebox(&["font", &two, "--out", ttf.to_str().unwrap(), "--font", "c"]);
assert_eq!(out.status.code(), Some(2));
let out = spritebox(&["font", &two, "--out", ttf.to_str().unwrap(), "--font", "b"]);
assert_eq!(out.status.code(), Some(0));
assert!(ttf.exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn imports_are_read_from_disk_relative_to_the_file() {
let dir = temp_dir("imports");
std::fs::create_dir_all(dir.join("art/shared")).unwrap();
write(
&dir,
"art/shared/palettes.spritebox",
"palette p\n k #000000\n x #00\n",
);
let main = write(
&dir,
"art/main.spritebox",
"import shared/palettes.spritebox\nsprite s 1x1\n k\n",
);
let out = spritebox(&["check", &main]);
assert_eq!(out.status.code(), Some(1));
let expected = dir.join("art").join("shared").join("palettes.spritebox");
let text = stderr(&out);
assert!(
text.contains(&format!(
"{}:3:5: error[invalid_argument]",
expected.display()
)),
"{text}"
);
let out = spritebox(&["check", &main, "--json"]);
assert!(
stdout(&out).contains(&format!("\"file\":{:?}", expected.display().to_string())),
"{}",
stdout(&out)
);
write(
&dir,
"art/shared/palettes.spritebox",
"palette p\n k #000000\n",
);
let out = spritebox(&["render", &main, "--out", dir.join("out").to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn render_does_not_write_invalid_files() {
let dir = temp_dir("render-invalid");
let file = write(&dir, "bad.spritebox", BAD);
let out_dir = dir.join("out");
let out = spritebox(&["render", &file, "--out", out_dir.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(1));
assert!(!out_dir.exists());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn usage_and_io_errors() {
for args in [
&[][..],
&["draw", COIN],
&["check"],
&["check", COIN, "--frobnicate"],
&["check", COIN, COIN],
&["render", COIN],
&["render", COIN, "--out"],
&["render", COIN, "--out", "x", "--scale", "0"],
&["check", COIN, "--out", "x"],
&["check", COIN, "--no-extrude"],
&["render", COIN, "--out", "x", "--max-side", "0"],
&["font", COIN],
&["render", COIN, "--out", "x", "--font", "hud"],
&["render", COIN, "--out", "x", "--padding", "65"],
&["lint", COIN, "--allow", "nope"],
&["lint", COIN, "--deny"],
&["lint", COIN, "--out", "x"],
&["check", COIN, "--strict"],
&["check", COIN, "--lint"],
&["lint", COIN, "--lint"],
&["render", COIN, "--out", "x", "--allow", "unused_key"],
] {
let out = spritebox(args);
assert_eq!(out.status.code(), Some(2), "{args:?}");
assert!(stderr(&out).contains("Usage:"), "{args:?}");
}
let out = spritebox(&["check", "/no/such/file.spritebox", "--json"]);
assert_eq!(out.status.code(), Some(3));
assert!(stdout(&out).contains("\"code\":\"io\""), "{}", stdout(&out));
let out = spritebox(&["--help"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout(&out).contains("spritebox check <file>"));
let out = spritebox(&["--version"]);
assert_eq!(
stdout(&out),
format!("spritebox {}\n", env!("CARGO_PKG_VERSION"))
);
}
#[test]
fn lint_reports_warnings() {
let dir = temp_dir("lint");
let file = dir.join("noisy.spritebox");
std::fs::write(
&file,
"palette p\n g #3a7d44\n h #468a50\n k #000000\n\
sprite leaf 3x3 palette=p\n ggg\n ghg\n ggg\n",
)
.unwrap();
let file = file.to_str().unwrap();
let out = spritebox(&["lint", file]);
assert_eq!(out.status.code(), Some(0));
let text = stderr(&out);
assert!(
text.contains(&format!(
"{file}:4:3: warning[unused_key]: Nothing uses key `k` of palette `p`"
)),
"{text}"
);
assert!(
text.contains(&format!("{file}:7:4: warning[orphan_pixel]: A lone pixel")),
"{text}"
);
assert!(text.ends_with(": 2 warnings\n"), "{text}");
assert_eq!(
spritebox(&["lint", file, "--strict"]).status.code(),
Some(1)
);
assert_eq!(
spritebox(&["lint", file, "--deny", "orphan_pixel"])
.status
.code(),
Some(1)
);
let out = spritebox(&[
"lint",
file,
"--allow",
"orphan_pixel",
"--deny",
"orphan_pixel",
]);
assert_eq!(out.status.code(), Some(0));
assert!(stderr(&out).ends_with(": 1 warning\n"), "{}", stderr(&out));
let out = spritebox(&["lint", file, "--json", "--strict"]);
assert_eq!(out.status.code(), Some(1));
let json = stdout(&out);
assert!(json.contains("\"ok\":false"), "{json}");
assert!(
json.contains("{\"code\":\"unused_key\",\"severity\":\"warning\",\"line\":4,\"column\":3,"),
"{json}"
);
let out = spritebox(&["lint", COIN, "--strict"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout(&out).ends_with(": ok\n"), "{}", stdout(&out));
let bad = dir.join("bad.spritebox");
std::fs::write(&bad, "palette p\n g #3a7d4\n").unwrap();
let out = spritebox(&["lint", bad.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(1));
assert!(
stderr(&out).contains("error[invalid_argument]"),
"{}",
stderr(&out)
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn lint_fails_on_warnings_past_the_limit() {
let dir = temp_dir("lint-limit");
let mut text = String::new();
let mut key = 0x100u32;
for p in 0..5 {
text.push_str(&format!("palette p{p}\n"));
for _ in 0..250 {
let k = char::from_u32(key).unwrap();
text.push_str(&format!(" {k} #000000\n"));
key += 1;
}
}
text.push_str(
"palette q\n g #3a7d44\n h #468a50\nsprite leaf 3x3 palette=q\n ggg\n ghg\n ggg\n",
);
let file = dir.join("many.spritebox");
std::fs::write(&file, text).unwrap();
let file = file.to_str().unwrap();
let out = spritebox(&["lint", file, "--deny", "orphan_pixel"]);
assert_eq!(out.status.code(), Some(1));
let text = stderr(&out);
assert!(
!text.contains("orphan_pixel"),
"the noise is past the limit"
);
assert!(
text.ends_with(": 1251 warnings, of which the first 1000 are shown\n"),
"{}",
text.lines().last().unwrap_or_default()
);
let out = spritebox(&["lint", file, "--json"]);
assert_eq!(out.status.code(), Some(0));
assert!(stdout(&out).contains("],\"omitted\":251,\"written\":[]}"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn render_marks_lint_on_the_preview() {
let dir = temp_dir("render-lint");
let example = concat!(env!("CARGO_MANIFEST_DIR"), "/examples/lint.spritebox");
let (plain, marked) = (dir.join("plain"), dir.join("marked"));
let out = spritebox(&["render", example, "--out", plain.to_str().unwrap()]);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
let out = spritebox(&[
"render",
example,
"--out",
marked.to_str().unwrap(),
"--lint",
]);
assert_eq!(out.status.code(), Some(0), "{}", stderr(&out));
let plain = spritebox::png::read_file(plain.join("preview.png")).unwrap();
let marked = spritebox::png::read_file(marked.join("preview.png")).unwrap();
assert!(marked.height() > plain.height());
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn ui_themes_and_screen_rendering() {
let dir = temp_dir("ui");
let file = write(
&dir,
"ui.spritebox",
"palette p\n w #ffffff\nsprite dot 1x1\n w\ntheme dark of p\n w #000000\nscreen view 1x1\n dot at 0,0\n",
);
let directory = dir.join("all");
let output = spritebox(&[
"render",
&file,
"--out",
directory.to_str().unwrap(),
"--theme",
"all",
"--preview",
]);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert!(directory.join("atlas-dark.png").is_file());
assert!(
std::fs::read_to_string(directory.join("spritebox.json"))
.unwrap()
.contains("atlas-dark.png")
);
let target = dir.join("view.png");
let output = spritebox(&[
"render",
&file,
"--screen",
"view",
"--theme",
"dark",
"--scale",
"3",
"--out",
target.to_str().unwrap(),
]);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
let image = spritebox::png::read_file(&target).unwrap();
assert_eq!((image.width(), image.height()), (3, 3));
assert_eq!(image.pixel(0, 0), Some(spritebox::Color::BLACK));
let output = spritebox(&[
"render",
&file,
"--screen",
"view",
"--out",
target.to_str().unwrap(),
]);
assert_eq!(output.status.code(), Some(0), "{}", stderr(&output));
assert_eq!(spritebox::png::read_file(&target).unwrap().width(), 1);
let missing = dir.join("missing.png");
let output = spritebox(&[
"render",
&file,
"--screen",
"absent",
"--out",
missing.to_str().unwrap(),
]);
assert_eq!(output.status.code(), Some(1));
assert!(!missing.exists());
let output = spritebox(&[
"render",
&file,
"--theme",
"absent",
"--out",
missing.to_str().unwrap(),
]);
assert_eq!(output.status.code(), Some(1));
assert!(!missing.exists());
let output = spritebox(&[
"render",
&file,
"--screen",
"view",
"--theme",
"all",
"--out",
missing.to_str().unwrap(),
]);
assert_eq!(output.status.code(), Some(2));
std::fs::remove_dir_all(dir).unwrap();
}