Bevy Forge 0.1.0: visual scene editor for Bevy 0.20 BSN

This commit is contained in:
Emil Shanaty
2026-10-27 18:52:10 +03:00
commit 13bf16d510
79 changed files with 44353 additions and 0 deletions
+644
View File
@@ -0,0 +1,644 @@
//! End-to-end tests for the `forge` binary.
//!
//! Scenes are produced through the core API so these tests exercise the real
//! serialisation path instead of hand-written RON.
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use bevy_forge_core::{ComponentData, Project, SceneDoc, SpriteData, Text2dData};
use serde_json::Value;
fn forge(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_forge"))
.args(args)
.output()
.expect("failed to spawn the forge binary")
}
fn exit_code(output: &Output) -> i32 {
output
.status
.code()
.expect("the forge process was terminated by a signal")
}
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()
}
/// Create a minimal Bevy project (manifest + empty assets dir) in `dir`.
fn create_project(dir: &Path) -> Project {
fs::create_dir_all(dir.join("assets")).unwrap();
fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"test-project\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\nbevy = \"0.20\"\n",
)
.unwrap();
Project::open(dir).unwrap()
}
fn save_scene(project: &Project, file: &str, document: &SceneDoc) -> PathBuf {
let path = project.scenes_dir().join(file);
project.save_scene(document, Some(&path)).unwrap();
path
}
/// A tiny scene with one textured sprite and one text entity.
fn sprite_scene(name: &str, image: &str) -> SceneDoc {
let mut document = SceneDoc::new(name);
let player = document.add_entity("Player", None);
document.add_component(
player,
ComponentData::Sprite(SpriteData {
image: Some(image.to_string()),
..Default::default()
}),
);
let label = document.add_entity("Label", None);
document.add_component(
label,
ComponentData::Text2d(Text2dData {
text: "Hello".to_string(),
..Default::default()
}),
);
document
}
/// Run `forge new` and return the project root plus its demo scene path.
fn scaffold_2d(dir: &Path) -> (PathBuf, PathBuf) {
let output = forge(&["new", dir.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"forge new failed\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let project = Project::open(dir).unwrap();
let scene = project.scenes_dir().join("demo.forge.ron");
(project.root.clone(), scene)
}
// ---------------------------------------------------------------------------
// Top-level CLI behaviour
// ---------------------------------------------------------------------------
#[test]
fn help_lists_every_subcommand() {
let output = forge(&["--help"]);
assert_eq!(exit_code(&output), 0, "stderr: {}", stderr(&output));
let text = stdout(&output);
for subcommand in ["new", "validate", "export", "info", "scenes"] {
assert!(
text.contains(subcommand),
"`{subcommand}` missing from --help output:\n{text}"
);
}
}
#[test]
fn version_prints_the_package_version() {
let output = forge(&["--version"]);
assert_eq!(exit_code(&output), 0, "stderr: {}", stderr(&output));
assert!(
stdout(&output).contains(env!("CARGO_PKG_VERSION")),
"unexpected version output: {}",
stdout(&output)
);
}
#[test]
fn unknown_command_exits_with_a_usage_error() {
let output = forge(&["frobnicate"]);
assert_eq!(exit_code(&output), 2);
assert!(!stderr(&output).is_empty());
}
// ---------------------------------------------------------------------------
// new
// ---------------------------------------------------------------------------
#[test]
fn new_creates_a_valid_project() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("my-game");
let output = forge(&["new", dir.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"forge new failed\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
for relative in [
"Cargo.toml",
"src/main.rs",
"assets/.gitkeep",
"scenes/demo.forge.ron",
".gitignore",
"README.md",
] {
assert!(dir.join(relative).is_file(), "missing {relative}");
}
let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
assert!(
manifest.contains("name = \"my-game\""),
"manifest: {manifest}"
);
assert!(manifest.contains("bevy = \"0.20\""), "manifest: {manifest}");
let project = Project::open(&dir).unwrap();
let scene_path = project.scenes_dir().join("demo.forge.ron");
let document = project.load_scene(&scene_path).unwrap();
assert!(
!document.entities.is_empty(),
"starter scene should contain entities"
);
assert!(
!document.roots.is_empty(),
"starter scene should have roots"
);
let output = forge(&["validate", scene_path.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"scaffolded scene should validate\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
}
#[test]
fn new_refuses_a_non_empty_directory() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("occupied");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("keep.txt"), "keep me").unwrap();
let output = forge(&["new", dir.to_str().unwrap()]);
assert_eq!(exit_code(&output), 2);
assert!(
stderr(&output).contains("not empty"),
"stderr: {}",
stderr(&output)
);
assert!(!dir.join("Cargo.toml").exists());
assert_eq!(fs::read_to_string(dir.join("keep.txt")).unwrap(), "keep me");
}
#[test]
fn new_fills_an_existing_empty_directory() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("already-there");
fs::create_dir_all(&dir).unwrap();
let output = forge(&["new", dir.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
assert!(dir.join("Cargo.toml").is_file());
assert!(dir.join("scenes/demo.forge.ron").is_file());
}
#[test]
fn new_accepts_name_and_3d_template() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("space");
let output = forge(&[
"new",
dir.to_str().unwrap(),
"--name",
"space-game",
"--template",
"3d",
]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let manifest = fs::read_to_string(dir.join("Cargo.toml")).unwrap();
assert!(
manifest.contains("name = \"space-game\""),
"manifest: {manifest}"
);
let project = Project::open(&dir).unwrap();
let document = project
.load_scene(project.scenes_dir().join("demo.forge.ron"))
.unwrap();
assert!(
document.stats().meshes > 0,
"the 3D starter scene should contain mesh entities"
);
}
// ---------------------------------------------------------------------------
// validate
// ---------------------------------------------------------------------------
#[test]
fn validate_accepts_a_good_scene_and_emits_json() {
let tmp = tempfile::tempdir().unwrap();
let (_root, scene) = scaffold_2d(&tmp.path().join("good-project"));
let output = forge(&["validate", scene.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
assert!(stdout(&output).contains("Summary:"));
let output = forge(&["validate", scene.to_str().unwrap(), "--json"]);
assert_eq!(exit_code(&output), 0);
let value: Value =
serde_json::from_str(&stdout(&output)).expect("validate --json must emit JSON");
assert_eq!(value["valid"], true);
assert_eq!(value["errors"], 0);
assert!(value["warnings"].is_u64());
assert!(value["issues"].is_array());
for issue in value["issues"].as_array().unwrap() {
for key in ["severity", "entity", "field", "message"] {
assert!(issue.get(key).is_some(), "issue missing `{key}`: {issue}");
}
assert!(["error", "warning", "info"].contains(&issue["severity"].as_str().unwrap()));
}
}
#[test]
fn validate_reports_parse_errors_with_exit_code_two() {
let tmp = tempfile::tempdir().unwrap();
let broken = tmp.path().join("broken.forge.ron");
fs::write(&broken, "this is definitely not RON").unwrap();
let output = forge(&["validate", broken.to_str().unwrap()]);
assert_eq!(exit_code(&output), 2);
assert!(
stderr(&output).contains("parse"),
"stderr should explain the parse error: {}",
stderr(&output)
);
assert!(stdout(&output).is_empty());
}
#[test]
fn validate_reports_a_missing_asset_as_a_validation_error() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Missing asset", "textures/missing.png");
let scene = save_scene(&project, "missing.forge.ron", &document);
let output = forge(&["validate", scene.to_str().unwrap()]);
assert_eq!(exit_code(&output), 1);
let text = stdout(&output);
assert!(text.contains("error"), "stdout: {text}");
assert!(text.contains("textures/missing.png"), "stdout: {text}");
assert!(text.contains("Summary:"), "stdout: {text}");
let output = forge(&["validate", scene.to_str().unwrap(), "--json"]);
assert_eq!(exit_code(&output), 1);
let value: Value = serde_json::from_str(&stdout(&output)).unwrap();
assert_eq!(value["valid"], false);
assert!(value["errors"].as_u64().unwrap() >= 1);
let issues = value["issues"].as_array().unwrap();
assert!(issues.iter().any(|issue| issue["message"]
.as_str()
.is_some_and(|message| message.contains("textures/missing.png"))));
}
#[test]
fn validate_uses_an_explicit_project_for_assets() {
let tmp = tempfile::tempdir().unwrap();
// The scene lives outside the project; `--project` must still resolve assets.
let project = create_project(&tmp.path().join("project"));
let outside = tmp.path().join("outside");
fs::create_dir_all(&outside).unwrap();
let document = sprite_scene("Outside", "textures/hero.png");
fs::create_dir_all(project.assets_dir.join("textures")).unwrap();
fs::write(project.assets_dir.join("textures/hero.png"), b"png").unwrap();
// Serialise inside the project, then move the file out of it.
let saved = save_scene(&project, "outside.forge.ron", &document);
let scene = outside.join("outside.forge.ron");
fs::copy(&saved, &scene).unwrap();
fs::remove_file(&saved).unwrap();
let output = forge(&[
"validate",
scene.to_str().unwrap(),
"--project",
project.root.to_str().unwrap(),
]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
}
// ---------------------------------------------------------------------------
// export
// ---------------------------------------------------------------------------
#[test]
fn export_bsn_to_stdout_and_file() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Export scene", "textures/player.png");
let scene = save_scene(&project, "export.forge.ron", &document);
let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let stdout_code = stdout(&output);
assert!(
stdout_code.starts_with("use bevy::prelude::*;"),
"generated code must start with the Bevy prelude import:\n{stdout_code}"
);
assert!(stdout_code.ends_with('\n'));
assert!(
stdout_code.contains("bsn!") || stdout_code.contains("bsn_list!"),
"expected BSN macro code:\n{stdout_code}"
);
let target = tmp.path().join("generated/nested/scene.rs");
let output = forge(&[
"export",
scene.to_str().unwrap(),
"--format",
"bsn",
"-o",
target.to_str().unwrap(),
]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let written = fs::read_to_string(&target).expect("output file must exist");
assert_eq!(written, stdout_code);
assert!(
target.parent().unwrap().is_dir(),
"parent directories must be created"
);
}
#[test]
fn export_bsn_single_root_uses_the_bsn_macro() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let mut document = SceneDoc::new("Single root");
let player = document.add_entity("Player", None);
document.add_component(
player,
ComponentData::Sprite(SpriteData {
image: Some("textures/player.png".to_string()),
..Default::default()
}),
);
let scene = save_scene(&project, "single.forge.ron", &document);
let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let code = stdout(&output);
assert!(code.contains("bsn! {"), "code:\n{code}");
assert!(!code.contains("bsn_list!"), "code:\n{code}");
assert!(code.contains("-> impl Scene"), "code:\n{code}");
}
#[test]
fn export_rust_format() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Export scene", "textures/player.png");
let scene = save_scene(&project, "export.forge.ron", &document);
let output = forge(&["export", scene.to_str().unwrap(), "--format", "rust"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let code = stdout(&output);
assert!(code.starts_with("use bevy::prelude::*;"), "code:\n{code}");
assert!(
code.contains("Commands"),
"expected a Commands parameter:\n{code}"
);
assert!(
code.contains("commands.spawn"),
"expected imperative spawn code:\n{code}"
);
assert!(code.ends_with('\n'));
}
#[test]
fn export_rejects_an_invalid_function_name() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Export scene", "textures/player.png");
let scene = save_scene(&project, "export.forge.ron", &document);
let output = forge(&[
"export",
scene.to_str().unwrap(),
"--format",
"bsn",
"--function",
"not a valid name",
]);
assert_eq!(exit_code(&output), 1);
assert!(
stderr(&output).contains("identifier"),
"stderr: {}",
stderr(&output)
);
assert!(stdout(&output).is_empty());
}
#[test]
fn export_reports_parse_errors_with_exit_code_two() {
let tmp = tempfile::tempdir().unwrap();
let broken = tmp.path().join("broken.forge.ron");
fs::write(&broken, "not ron at all").unwrap();
let target = tmp.path().join("never-written.rs");
let output = forge(&[
"export",
broken.to_str().unwrap(),
"-o",
target.to_str().unwrap(),
]);
assert_eq!(exit_code(&output), 2);
assert!(
!target.exists(),
"no output must be written on a parse error"
);
}
// ---------------------------------------------------------------------------
// info
// ---------------------------------------------------------------------------
#[test]
fn info_json_has_the_documented_fields() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
let document = sprite_scene("Info scene", "textures/hero.png");
let scene = save_scene(&project, "info.forge.ron", &document);
let output = forge(&["info", scene.to_str().unwrap(), "--json"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let value: Value = serde_json::from_str(&stdout(&output)).expect("info --json must emit JSON");
assert_eq!(value["name"], "Info scene");
assert_eq!(value["entities"], 2);
assert_eq!(value["roots"], 2);
assert!(value["path"]
.as_str()
.is_some_and(|path| Path::new(path).is_absolute()));
assert_eq!(value["components"]["sprite"], 1);
assert_eq!(value["components"]["text2d"], 1);
let references = value["asset_references"].as_array().unwrap();
assert!(references
.iter()
.any(|reference| reference["path"] == "textures/hero.png"));
assert!(value["validation"].is_object());
// A missing texture is an error, but `info` itself always succeeds.
assert_eq!(value["validation"]["valid"], false);
let output = forge(&["info", scene.to_str().unwrap()]);
assert_eq!(exit_code(&output), 0);
let text = stdout(&output);
assert!(text.contains("Scene:"));
assert!(text.contains("Entities: 2"));
assert!(text.contains("Validation:"));
}
// ---------------------------------------------------------------------------
// scenes
// ---------------------------------------------------------------------------
#[test]
fn scenes_lists_absolute_paths_in_sorted_order() {
let tmp = tempfile::tempdir().unwrap();
let project = create_project(tmp.path());
save_scene(&project, "beta.forge.ron", &SceneDoc::new("Beta"));
save_scene(&project, "alpha.forge.ron", &SceneDoc::new("Alpha"));
let output = forge(&["scenes", project.root.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let lines: Vec<String> = stdout(&output).lines().map(ToOwned::to_owned).collect();
let expected: Vec<String> = project
.list_scenes()
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect();
assert_eq!(lines, expected);
assert_eq!(lines.len(), 2);
assert!(lines.iter().all(|line| Path::new(line).is_absolute()));
assert!(lines[0].ends_with("alpha.forge.ron"));
}
#[test]
fn scenes_rejects_a_directory_that_is_not_a_bevy_project() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"not-bevy\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let output = forge(&["scenes", tmp.path().to_str().unwrap()]);
assert_eq!(exit_code(&output), 2);
assert!(
stderr(&output).contains("not a Bevy project"),
"stderr: {}",
stderr(&output)
);
}
// ---------------------------------------------------------------------------
// Demo project integration
// ---------------------------------------------------------------------------
#[test]
fn demo_project_scene_validates_and_exports() {
let demo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/demo-project");
let scene = demo.join("scenes/garden.forge.ron");
assert!(
scene.is_file(),
"demo project scene is missing: {}",
scene.display()
);
let output = forge(&["validate", scene.to_str().unwrap()]);
assert_eq!(
exit_code(&output),
0,
"demo garden scene should validate\nstdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
let output = forge(&["export", scene.to_str().unwrap(), "--format", "bsn"]);
assert_eq!(
exit_code(&output),
0,
"stdout: {}\nstderr: {}",
stdout(&output),
stderr(&output)
);
assert!(stdout(&output).starts_with("use bevy::prelude::*;"));
let output = forge(&["scenes", demo.to_str().unwrap()]);
assert_eq!(exit_code(&output), 0);
assert!(stdout(&output).contains("garden.forge.ron"));
}