use crate::{NodeIdx, SceneKind, SceneProblem, parse_scene};
const MAIN_TSCN: &str = "[gd_scene load_steps=2 format=3]\n\
\n\
[ext_resource type=\"Script\" path=\"res://examples/app.gd\" id=\"1_app\"]\n\
\n\
[node name=\"Main\" type=\"Control\"]\n\
layout_mode = 3\n\
anchors_preset = 15\n\
script = ExtResource(\"1_app\")\n";
#[test]
fn parses_the_target_main_tscn() {
let m = parse_scene(MAIN_TSCN);
assert_eq!(m.kind, SceneKind::Scene);
assert_eq!(m.format, Some(3));
assert!(m.problems.is_empty(), "{:?}", m.problems);
assert_eq!(m.ext_resources.len(), 1);
assert_eq!(m.nodes.len(), 1);
let root = m.root.expect("a root");
let n = m.node(root).unwrap();
assert_eq!(n.name, "Main");
assert_eq!(n.decl_type.as_deref(), Some("Control"));
assert!(n.parent_path.is_none(), "root has no parent");
assert_eq!(n.script.as_ref().map(|e| e.0.as_str()), Some("1_app"));
assert_eq!(m.node_with_script("res://examples/app.gd"), Some(root));
}
#[test]
fn header_matrix() {
let s = parse_scene("[gd_scene format=3 uid=\"uid://abc\" script_class=\"Foo\"]\n");
assert_eq!(s.kind, SceneKind::Scene);
assert_eq!(s.uid.as_deref(), Some("uid://abc"));
assert_eq!(s.script_class.as_deref(), Some("Foo"));
let r = parse_scene("[gd_resource type=\"Resource\" script_class=\"Dialogue\" format=3]\n");
assert_eq!(r.kind, SceneKind::Resource);
assert_eq!(r.resource_type.as_deref(), Some("Resource"));
assert_eq!(r.script_class.as_deref(), Some("Dialogue"));
let l = parse_scene("[gd_scene load_steps=99 format=3]\n");
assert_eq!(l.format, Some(3));
assert!(l.uid.is_none());
}
#[test]
fn ext_resource_matrix() {
let m = parse_scene(
"[gd_scene format=3]\n\
[ext_resource type=\"Script\" uid=\"uid://x\" path=\"res://a.gd\" id=\"1_app\"]\n\
[ext_resource type=\"PackedScene\" path=\"res://b.tscn\" id=1]\n\
[ext_resource type=\"Texture2D\" id=\"3\"]\n",
);
let s = m.ext_resources.get(&crate::ExtId("1_app".into())).unwrap();
assert_eq!(s.res_type, "Script");
assert_eq!(s.path.as_deref(), Some("res://a.gd"));
assert_eq!(s.uid.as_deref(), Some("uid://x"));
assert!(m.ext_resources.contains_key(&crate::ExtId("1".into())));
assert!(m.ext_resources.contains_key(&crate::ExtId("3".into())));
assert!(
m.problems
.iter()
.any(|p| matches!(p, SceneProblem::MissingExtField { .. }))
);
}
#[test]
fn node_tree_paths_and_children() {
let m = parse_scene(
"[gd_scene format=3]\n\
[node name=\"Root\" type=\"Control\"]\n\
[node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
[node name=\"VBox\" type=\"VBoxContainer\" parent=\"Panel\"]\n\
[node name=\"StartButton\" type=\"Button\" parent=\"Panel/VBox\"]\n",
);
assert!(m.problems.is_empty(), "{:?}", m.problems);
assert_eq!(m.nodes.len(), 4);
let root = m.root.unwrap();
let btn = m
.resolve_path("Panel/VBox/StartButton")
.expect("button by path");
assert_eq!(m.node(btn).unwrap().name, "StartButton");
assert_eq!(m.node(btn).unwrap().decl_type.as_deref(), Some("Button"));
assert_eq!(m.by_path.get("Panel/VBox/StartButton").copied(), Some(btn));
assert!(m.resolve_path("Panel/Nope").is_none());
let panel = m.resolve_path("Panel").unwrap();
assert_eq!(m.resolve_path_from(panel, "VBox/StartButton"), Some(btn));
assert_eq!(m.resolve_path_from(panel, "."), Some(panel));
let root_children: Vec<_> = m
.children_of(Some(root))
.map(|(_, n)| n.name.as_str())
.collect();
assert_eq!(root_children, vec!["Panel"]);
let none_is_root: Vec<_> = m.children_of(None).map(|(_, n)| n.name.as_str()).collect();
assert_eq!(none_is_root, vec!["Panel"]);
assert!(m.resolve_path("/root/Panel").is_none());
assert!(m.resolve_path_from(panel, "../Root").is_none());
}
#[test]
fn instanced_child_and_inherited_root() {
let child = parse_scene(
"[gd_scene format=3]\n\
[ext_resource type=\"PackedScene\" path=\"res://player.tscn\" id=\"1\"]\n\
[node name=\"Root\" type=\"Node\"]\n\
[node name=\"Player\" parent=\".\" instance=ExtResource(\"1\")]\n",
);
let player = child.resolve_path("Player").unwrap();
let pn = child.node(player).unwrap();
assert!(pn.decl_type.is_none(), "instanced node has no type=");
assert_eq!(pn.instance.as_ref().map(|e| e.0.as_str()), Some("1"));
assert!(!pn.instance_is_inherited_root);
let inherited = parse_scene(
"[gd_scene format=3]\n\
[ext_resource type=\"PackedScene\" path=\"res://base.tscn\" id=\"1\"]\n\
[node name=\"Derived\" instance=ExtResource(\"1\")]\n",
);
let r = inherited.root.unwrap();
assert!(
inherited.node(r).unwrap().instance_is_inherited_root,
"root + instance= ⇒ inherited"
);
}
#[test]
fn unique_name_in_owner_is_a_body_bool_not_the_header_unique_id() {
let m = parse_scene(
"[gd_scene format=3]\n\
[node name=\"Root\" type=\"Control\"]\n\
[node name=\"Tabs\" type=\"TabContainer\" parent=\".\" unique_id=12345]\n\
unique_name_in_owner = true\n",
);
assert!(m.problems.is_empty(), "{:?}", m.problems);
let tabs = m.resolve_unique("Tabs").expect("%Tabs resolves");
assert!(m.node(tabs).unwrap().unique_name_in_owner);
assert_eq!(m.node(tabs).unwrap().name, "Tabs");
}
#[test]
fn header_value_lexer_tolerates_unquoted_ints_arrays_constructors() {
let m = parse_scene(
"[gd_scene format=3]\n\
[node name=\"N\" type=\"Button\" unique_id=1975992027 groups=[\"mobs\",\"ui\"] \
node_paths=PackedStringArray(\"p\") index=\"0\"]\n",
);
assert!(
m.problems
.iter()
.all(|p| !matches!(p, SceneProblem::MalformedHeader { .. })),
"header must lex cleanly: {:?}",
m.problems
);
let n = m.node(m.root.unwrap()).unwrap();
assert_eq!(n.name, "N");
assert_eq!(n.decl_type.as_deref(), Some("Button"));
}
#[test]
fn value_skipper_handles_multiline_color_and_embedded_newline() {
let m = parse_scene(
"[gd_scene format=3]\n\
[node name=\"L\" type=\"Label\"]\n\
colors = [#ff0000, #00ff00]\n\
data = {\n\
\t\"a\": 1,\n\
\t\"b\": [1, 2],\n\
}\n\
text = \"two\nlines\"\n\
unique_name_in_owner = true\n",
);
assert!(m.problems.is_empty(), "{:?}", m.problems);
assert!(
m.node(m.root.unwrap()).unwrap().unique_name_in_owner,
"skipper consumed too much"
);
}
#[test]
fn connection_and_tres_and_editable_are_recognized_not_errors() {
let scene = parse_scene(
"[gd_scene format=3]\n\
[node name=\"Root\" type=\"Node\"]\n\
[node name=\"Player\" type=\"Node\" parent=\".\"]\n\
[connection signal=\"hit\" from=\"Player\" to=\".\" method=\"game_over\"]\n",
);
assert!(
scene
.problems
.iter()
.all(|p| !matches!(p, SceneProblem::UnknownTag { .. })),
"connection must be recognized: {:?}",
scene.problems
);
let tres = parse_scene(
"[gd_resource type=\"Animation\" format=3]\n\
[resource]\n\
length = 1.5\n\
tracks/0/type = \"value\"\n",
);
assert_eq!(tres.kind, SceneKind::Resource);
assert_eq!(tres.resource_type.as_deref(), Some("Animation"));
assert!(tres.problems.is_empty(), "{:?}", tres.problems);
}
#[test]
fn degrade_binary_and_unknown_tag() {
let bin = parse_scene("RSRC\u{1}\u{2}\u{3}binary junk");
assert!(bin.problems.contains(&SceneProblem::BinaryResource));
assert!(bin.nodes.is_empty());
let unknown = parse_scene(
"[gd_scene format=3]\n\
[weird_tag foo=\"bar\"]\n\
baz = 1\n\
[node name=\"Root\" type=\"Node\"]\n",
);
assert!(
unknown
.problems
.iter()
.any(|p| matches!(p, SceneProblem::UnknownTag { .. }))
);
assert_eq!(unknown.nodes.len(), 1);
}
#[test]
fn degrade_multiple_roots_no_root_and_dangling_parent() {
let multi = parse_scene(
"[gd_scene format=3]\n[node name=\"A\" type=\"Node\"]\n[node name=\"B\" type=\"Node\"]\n",
);
assert!(
multi
.problems
.iter()
.any(|p| matches!(p, SceneProblem::MultipleRoots { .. }))
);
assert_eq!(multi.root, Some(NodeIdx(0)));
let dangling = parse_scene(
"[gd_scene format=3]\n\
[node name=\"Root\" type=\"Node\"]\n\
[node name=\"Lost\" type=\"Node\" parent=\"Ghost/Path\"]\n",
);
assert!(
dangling
.problems
.iter()
.any(|p| matches!(p, SceneProblem::DanglingParent { .. }))
);
assert!(dangling.resolve_path("Ghost/Path/Lost").is_none());
}
#[test]
fn inline_subresource_script_is_not_a_dangling_ext_resource() {
let m = parse_scene(
"[gd_scene format=3]\n\
[sub_resource type=\"GDScript\" id=\"GDScript_x\"]\n\
script/source = \"extends Node\"\n\
[node name=\"Root\" type=\"Node\"]\n\
script = SubResource(\"GDScript_x\")\n",
);
assert!(
m.problems
.iter()
.all(|p| !matches!(p, SceneProblem::UnknownExtResource { .. })),
"inline SubResource script must not be a dangling ext-resource: {:?}",
m.problems
);
assert!(m.node(m.root.unwrap()).unwrap().script.is_none());
}
#[test]
fn parent_into_instanced_subscene_is_not_dangling_but_a_real_typo_is() {
let ok = parse_scene(
"[gd_scene format=3]\n\
[ext_resource type=\"PackedScene\" path=\"res://bot.tscn\" id=\"1\"]\n\
[node name=\"Root\" type=\"Node3D\"]\n\
[node name=\"Bot\" parent=\".\" instance=ExtResource(\"1\")]\n\
[node name=\"Extra\" type=\"Node3D\" parent=\"Bot/Armature/Skeleton3D\"]\n",
);
assert!(
ok.problems
.iter()
.all(|p| !matches!(p, SceneProblem::DanglingParent { .. })),
"an override into an instanced sub-scene must not be dangling: {:?}",
ok.problems
);
let typo = parse_scene(
"[gd_scene format=3]\n\
[node name=\"Root\" type=\"Control\"]\n\
[node name=\"Panel\" type=\"Panel\" parent=\".\"]\n\
[node name=\"X\" type=\"Label\" parent=\"Panl\"]\n",
);
assert!(
typo.problems
.iter()
.any(|p| matches!(p, SceneProblem::DanglingParent { .. })),
"a real typo'd parent must still flag: {:?}",
typo.problems
);
}
#[test]
fn override_children_under_an_inherited_root_are_not_dangling() {
let m = parse_scene(
"[gd_scene format=3]\n\
[ext_resource type=\"PackedScene\" path=\"res://base.tscn\" id=\"1\"]\n\
[node name=\"Client\" instance=ExtResource(\"1\")]\n\
[node name=\"Panel\" parent=\".\"]\n\
[node name=\"VBoxContainer\" parent=\"Panel\"]\n\
[node name=\"Port\" type=\"SpinBox\" parent=\"Panel/VBoxContainer/Connect\"]\n",
);
assert!(
m.problems
.iter()
.all(|p| !matches!(p, SceneProblem::DanglingParent { .. })),
"override under an inherited root must not be dangling: {:?}",
m.problems
);
}
#[test]
fn escape_parent_paths_degrade_silently_not_dangling() {
for parent in ["../Sibling", "/root/R", "/R", "A/../B"] {
let src = format!(
"[gd_scene format=3]\n\
[node name=\"R\" type=\"Node\"]\n\
[node name=\"A\" type=\"Node\" parent=\".\"]\n\
[node name=\"B\" type=\"Node\" parent=\"{parent}\"]\n"
);
let m = parse_scene(&src);
assert!(
m.problems
.iter()
.all(|p| !matches!(p, SceneProblem::DanglingParent { .. })),
"parent={parent:?} must not dangle: {:?}",
m.problems
);
}
let typo = parse_scene(
"[gd_scene format=3]\n[node name=\"R\" type=\"Node\"]\n[node name=\"B\" type=\"Node\" parent=\"Nope\"]\n",
);
assert!(
typo.problems
.iter()
.any(|p| matches!(p, SceneProblem::DanglingParent { .. }))
);
}
#[test]
fn duplicate_sibling_first_wins_for_path_resolution() {
let m = parse_scene(
"[gd_scene format=3]\n\
[node name=\"R\" type=\"Node\"]\n\
[node name=\"Dup\" type=\"Label\" parent=\".\"]\n\
[node name=\"Dup\" type=\"Button\" parent=\".\"]\n",
);
let dup = m.resolve_path("Dup").unwrap();
assert_eq!(
m.node(dup).unwrap().decl_type.as_deref(),
Some("Label"),
"first sibling wins"
);
assert_eq!(
m.children_of(m.root).count(),
2,
"children_of lists both siblings"
);
}
#[test]
fn never_panics_on_garbage() {
for g in [
"",
" \n\n ",
"[",
"[node",
"[node name=",
"[node name=\"unterminated",
"garbage not a scene at all",
"[gd_scene format=3]\n[node name=\"a\" parent=\"a\"]\n", "[node name=\"x\"]\n}}}]]])))\n",
"\u{feff}[gd_scene format=3]\n", ] {
let _ = parse_scene(g); }
}