1use std::collections::{BTreeMap, HashSet};
8use std::path::{Path, PathBuf};
9
10use anyhow::{Context, Result};
11use pite_core::{NodeDesc, NodeId, NodeTree, NodeTypeRegistry, PropValue, Props};
12use pite_scene::{SceneDoc, SceneInstance, SceneNode, SceneScript, FORMAT_VERSION};
13
14pub fn registered_types() -> Vec<String> {
17 [
18 "Node",
19 "Node2D",
20 "Sprite2D",
21 "Camera2D",
22 "Timer",
23 "Label",
24 "Button",
25 "StaticBody2D",
26 "CharacterBody2D",
27 "RigidBody2D",
28 "Area2D",
29 "CollisionShape2D",
30 ]
31 .iter()
32 .map(ToString::to_string)
33 .collect()
34}
35
36fn slugify(name: &str) -> String {
37 let mut slug: String = name
38 .chars()
39 .map(|c| {
40 if c.is_ascii_alphanumeric() {
41 c.to_ascii_lowercase()
42 } else {
43 '_'
44 }
45 })
46 .collect();
47 slug = slug.trim_matches('_').to_string();
48 if slug.is_empty() {
49 slug = "node".to_string();
50 }
51 slug
52}
53
54pub fn add_node(
58 tree: &mut NodeTree,
59 parent: Option<NodeId>,
60 type_name: &str,
61 name: &str,
62) -> Result<NodeId> {
63 let registry = NodeTypeRegistry::new();
64 let slug = slugify(name);
65 let mut candidate = slug.clone();
66 let mut counter = 2;
67 while tree.contains(&NodeId::from(candidate.clone())) {
68 candidate = format!("{slug}_{counter}");
69 counter += 1;
70 }
71 let id = NodeId::from(candidate);
72 let mut node = registry
73 .create(id.clone(), type_name)
74 .map_err(|e| anyhow::anyhow!("{e}"))?;
75 node.name = name.to_string();
76
77 match type_name {
78 "StaticBody2D" | "Area2D" => {
79 node.props
80 .insert("position".to_string(), PropValue::Vec2(0.0, 0.0));
81 }
82 "CharacterBody2D" => {
83 node.props
84 .insert("position".to_string(), PropValue::Vec2(0.0, 0.0));
85 node.props
86 .insert("velocity".to_string(), PropValue::Vec2(0.0, 0.0));
87 }
88 "RigidBody2D" => {
89 node.props
90 .insert("position".to_string(), PropValue::Vec2(0.0, 0.0));
91 node.props.insert("mass".to_string(), PropValue::Num(1.0));
92 node.props
93 .insert("gravity_scale".to_string(), PropValue::Num(1.0));
94 node.props
95 .insert("lock_rotation".to_string(), PropValue::Bool(false));
96 }
97 "CollisionShape2D" => {
98 node.props
99 .insert("position".to_string(), PropValue::Vec2(0.0, 0.0));
100 node.props
101 .insert("shape".to_string(), PropValue::Str("box".to_string()));
102 node.props
103 .insert("size".to_string(), PropValue::Vec2(32.0, 32.0));
104 node.props
105 .insert("radius".to_string(), PropValue::Num(16.0));
106 node.props
107 .insert("height".to_string(), PropValue::Num(32.0));
108 node.props
109 .insert("disabled".to_string(), PropValue::Bool(false));
110 }
111 _ => {}
112 }
113 let parent = match parent {
114 Some(p) => {
115 if !tree.contains(&p) {
116 anyhow::bail!("parent `{p}` does not exist");
117 }
118 Some(p)
119 }
120 None => tree.root().cloned(),
121 };
122 let desc = NodeDesc {
123 id: id.clone(),
124 type_name: node.type_name.clone(),
125 name: node.name.clone(),
126 parent,
127 props: node.props.clone(),
128 script: None,
129 };
130 tree.insert(desc).map_err(|e| anyhow::anyhow!("{e}"))?;
131 Ok(id)
132}
133
134pub fn remove_node(tree: &mut NodeTree, id: &NodeId) -> Result<()> {
136 if tree.root() == Some(id) {
137 anyhow::bail!("cannot remove the root node `{id}`");
138 }
139 tree.remove(id).map_err(|e| anyhow::anyhow!("{e}"))?;
140 Ok(())
141}
142
143pub fn move_node(tree: &mut NodeTree, id: &NodeId, new_parent: Option<NodeId>) -> Result<()> {
145 tree.reparent(id, new_parent)
146 .map_err(|e| anyhow::anyhow!("{e}"))?;
147 Ok(())
148}
149
150pub fn place_node(
155 tree: &mut NodeTree,
156 id: &NodeId,
157 new_parent: &NodeId,
158 index: usize,
159) -> Result<()> {
160 if tree.root() == Some(id) {
161 anyhow::bail!("cannot move the root node `{id}`");
162 }
163 let node = tree
164 .get(id)
165 .ok_or_else(|| anyhow::anyhow!("node `{id}` does not exist"))?;
166 if !tree.contains(new_parent) {
167 anyhow::bail!("parent `{new_parent}` does not exist");
168 }
169 if new_parent == id {
170 anyhow::bail!("cannot place `{id}` under itself");
171 }
172 let mut cursor = Some(new_parent.clone());
173 while let Some(current) = cursor {
174 if ¤t == id {
175 anyhow::bail!("cannot place `{id}` under its descendant `{new_parent}`");
176 }
177 cursor = tree.get(¤t).and_then(|n| n.parent.clone());
178 }
179 let old_parent = node.parent.clone();
180 if let Some(old) = &old_parent {
181 if let Some(n) = tree.get_mut(old) {
182 n.children.retain(|c| c != id);
183 }
184 }
185 let node_parent = tree
186 .get_mut(id)
187 .ok_or_else(|| anyhow::anyhow!("node `{id}` does not exist"))?;
188 node_parent.parent = Some(new_parent.clone());
189 let host = tree
190 .get_mut(new_parent)
191 .ok_or_else(|| anyhow::anyhow!("parent `{new_parent}` does not exist"))?;
192 let at = index.min(host.children.len());
193 host.children.insert(at, id.clone());
194 Ok(())
195}
196
197pub fn move_sibling(tree: &mut NodeTree, id: &NodeId, delta: i32) -> Result<()> {
201 let node = tree
202 .get(id)
203 .ok_or_else(|| anyhow::anyhow!("node `{id}` does not exist"))?;
204 let parent = node
205 .parent
206 .clone()
207 .ok_or_else(|| anyhow::anyhow!("node `{id}` has no parent to reorder in"))?;
208 let siblings = tree.children_of(&parent);
209 let pos = siblings
210 .iter()
211 .position(|s| s == id)
212 .ok_or_else(|| anyhow::anyhow!("node `{id}` is not listed under its parent"))?;
213 let len = siblings.len() as i32;
214 let at = (pos as i32 + delta).clamp(0, len - 1) as usize;
215 place_node(tree, id, &parent, at)
216}
217
218pub fn set_prop(tree: &mut NodeTree, id: &NodeId, key: &str, value: PropValue) -> Result<()> {
220 match tree.get_mut(id) {
221 Some(node) => {
222 node.props.insert(key.to_string(), value);
223 Ok(())
224 }
225 None => anyhow::bail!("node `{id}` does not exist"),
226 }
227}
228
229fn prop_to_toml(value: &PropValue) -> toml::Value {
230 match value {
231 PropValue::Str(s) => toml::Value::String(s.clone()),
232 PropValue::Num(f) => toml::Value::Float(*f),
233 PropValue::Int(i) => toml::Value::Integer(*i),
234 PropValue::Bool(b) => toml::Value::Boolean(*b),
235 PropValue::Vec2(x, y) => {
236 toml::Value::Array(vec![toml::Value::Float(*x), toml::Value::Float(*y)])
237 }
238 }
239}
240
241#[derive(Clone, Copy)]
243pub struct InstanceRefs<'a> {
244 pub scene_dir: &'a Path,
245 pub project_dir: Option<&'a Path>,
246}
247
248pub fn build_doc(
258 tree: &NodeTree,
259 source: Option<&SceneDoc>,
260 refs: Option<InstanceRefs<'_>>,
261) -> Result<SceneDoc> {
262 if let Some(source) = source {
263 if !source.instance.is_empty() && refs.is_none() {
264 anyhow::bail!(
265 "cannot save: {} instance(s) need scene/project paths to resolve",
266 source.instance.len()
267 );
268 }
269 }
270 let root = tree.root().map(ToString::to_string).unwrap_or_default();
271 let mut owned: HashSet<String> = HashSet::new();
272 let mut instance: Vec<SceneInstance> = Vec::new();
273 if let (Some(source), Some(refs)) = (source, refs) {
274 for inst in &source.instance {
275 let plan = plan_instance(inst, tree, &root, refs)
276 .with_context(|| format!("cannot save instance of {}", inst.scene))?;
277 owned.extend(plan.owned);
278 instance.push(plan.entry);
279 }
280 }
281 let mut node = Vec::new();
282 for n in tree.iter() {
283 if owned.contains(n.id.as_str()) {
284 continue;
285 }
286 if let Some(parent) = &n.parent {
287 if owned.contains(parent.as_str()) {
288 anyhow::bail!(
289 "node {:?} is parented inside an instanced subtree; the scene format \
290 cannot express that",
291 n.id
292 );
293 }
294 }
295 node.push(SceneNode {
296 id: n.id.to_string(),
297 type_name: n.type_name.clone(),
298 name: n.name.clone(),
299 parent: n.parent.clone().map(|p| p.to_string()),
300 props: props_to_toml(&n.props),
301 script: n.script.as_ref().map(|s| SceneScript {
302 path: s.path.clone(),
303 class: s.class_name.clone(),
304 }),
305 });
306 }
307 if let Some(source) = source {
308 reject_foreign_nodes(tree, source, &owned)?;
309 }
310 Ok(SceneDoc {
311 format_version: FORMAT_VERSION,
312 root,
313 node,
314 instance,
315 })
316}
317
318fn props_to_toml(props: &Props) -> BTreeMap<String, toml::Value> {
319 let mut out = BTreeMap::new();
320 for (k, v) in &props.0 {
321 out.insert(k.clone(), prop_to_toml(v));
322 }
323 out
324}
325
326struct InstancePlan {
328 entry: SceneInstance,
329 owned: HashSet<String>,
330}
331
332fn resolve_instance_path(scene_ref: &str, refs: InstanceRefs<'_>) -> Result<PathBuf> {
333 if let Some(rel) = scene_ref.strip_prefix("res://") {
334 let root = refs
335 .project_dir
336 .context("instance uses res:// but no project root is known")?;
337 Ok(root.join(rel))
338 } else {
339 let p = Path::new(scene_ref);
340 if p.is_absolute() {
341 Ok(p.to_path_buf())
342 } else {
343 Ok(refs.scene_dir.join(p))
344 }
345 }
346}
347
348fn plan_instance(
352 inst: &SceneInstance,
353 tree: &NodeTree,
354 doc_root: &str,
355 refs: InstanceRefs<'_>,
356) -> Result<InstancePlan> {
357 let path = resolve_instance_path(&inst.scene, refs)?;
358 let ref_doc = pite_scene::load_scene(&path)?;
359 let mut owned = HashSet::new();
360 let mut overrides: BTreeMap<String, toml::Value> = BTreeMap::new();
361 for ref_node in &ref_doc.node {
362 let live_id = format!("{}{}", inst.prefix, ref_node.id);
363 let live = tree
364 .get(&NodeId::from(live_id.clone()))
365 .with_context(|| format!("live tree is missing `{live_id}`"))?;
366 owned.insert(live_id.clone());
367 if live.type_name != ref_node.type_name || live.name != ref_node.name {
368 anyhow::bail!("`{live_id}` was renamed or retyped; overrides carry props only");
369 }
370 let live_script = live
371 .script
372 .as_ref()
373 .map(|s| (s.path.as_str(), s.class_name.as_str()));
374 let ref_script = ref_node
375 .script
376 .as_ref()
377 .map(|s| (s.path.as_str(), s.class.as_str()));
378 if live_script != ref_script {
379 anyhow::bail!("script on `{live_id}` cannot be overridden per instance");
380 }
381 if ref_node.id != ref_doc.root {
382 let expected = ref_node
383 .parent
384 .as_ref()
385 .map(|p| format!("{}{p}", inst.prefix));
386 if live.parent.as_ref().map(ToString::to_string) != expected {
387 anyhow::bail!("`{live_id}` was reparented inside the instance");
388 }
389 }
390 for key in ref_node.props.keys() {
391 if !live.props.0.contains_key(key) {
392 anyhow::bail!(
393 "prop `{key}` was removed from `{live_id}`; an instance cannot \
394 unset an inherited prop"
395 );
396 }
397 }
398 for (key, value) in props_to_toml(&live.props) {
399 if ref_node.props.get(&key) != Some(&value) {
400 overrides.insert(format!("{}.{}", ref_node.id, key), value);
401 }
402 }
403 }
404 let subtree_root = format!("{}{}", inst.prefix, ref_doc.root);
405 let attach = tree
406 .get(&NodeId::from(subtree_root.clone()))
407 .and_then(|n| n.parent.clone())
408 .map(|p| p.to_string());
409 let parent = match (&inst.parent, attach.as_deref()) {
410 (Some(original), Some(current)) if original == current => Some(original.clone()),
411 (None, Some(current)) if current == doc_root => None,
412 (_, Some(current)) => Some(current.to_string()),
413 (_, None) => None,
414 };
415 Ok(InstancePlan {
416 entry: SceneInstance {
417 scene: inst.scene.clone(),
418 parent,
419 prefix: inst.prefix.clone(),
420 overrides,
421 },
422 owned,
423 })
424}
425
426fn reject_foreign_nodes(tree: &NodeTree, source: &SceneDoc, owned: &HashSet<String>) -> Result<()> {
429 let authored: HashSet<&str> = source.node.iter().map(|n| n.id.as_str()).collect();
430 for n in tree.iter() {
431 let id = n.id.as_str();
432 if authored.contains(id) || owned.contains(id) {
433 continue;
434 }
435 let mut cursor = n.parent.clone();
436 while let Some(parent) = cursor {
437 if owned.contains(parent.as_str()) {
438 anyhow::bail!(
439 "node `{id}` was added inside an instanced subtree; the scene \
440 format cannot express that"
441 );
442 }
443 cursor = tree.get(&parent).and_then(|p| p.parent.clone());
444 }
445 }
446 Ok(())
447}
448
449pub fn gutter_marks(errors: &[String]) -> Vec<(usize, String)> {
454 let mut out = Vec::new();
455 for err in errors {
456 let last = err
457 .lines()
458 .map(str::trim)
459 .filter(|l| !l.is_empty())
460 .last()
461 .unwrap_or("");
462 let mut line_no: Option<usize> = None;
463 for (idx, _) in err.match_indices("line ") {
464 let rest = &err[idx + "line ".len()..];
465 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
466 if !digits.is_empty() {
467 if let Ok(n) = digits.parse::<usize>() {
468 line_no = Some(n);
469 }
470 }
471 }
472 if let Some(n) = line_no {
473 out.push((n, last.to_string()));
474 }
475 }
476 out
477}
478
479pub fn save_text(path: &Path, text: &str) -> Result<()> {
481 std::fs::write(path, text).with_context(|| format!("cannot write {}", path.display()))?;
482 Ok(())
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488 use pite_core::NodeId;
489 use std::sync::atomic::{AtomicU64, Ordering};
490
491 static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
492
493 const ENEMY_SCENE: &str = r#"format_version = 1
494root = "enemy"
495
496[[node]]
497id = "enemy"
498type = "Node2D"
499name = "Enemy"
500
501[[node]]
502id = "sprite"
503type = "Sprite2D"
504name = "Sprite"
505parent = "enemy"
506
507[node.props]
508position = [0.0, 0.0]
509"#;
510
511 const HOST_SCENE: &str = r#"format_version = 1
512root = "root"
513
514[[node]]
515id = "root"
516type = "Node2D"
517name = "Main"
518
519[[node]]
520id = "player"
521type = "Sprite2D"
522name = "Player"
523parent = "root"
524
525[node.props]
526texture = "res://assets/player.png"
527
528[[instance]]
529scene = "res://scenes/enemy.ptscn"
530parent = "root"
531prefix = "e1_"
532
533[instance.overrides]
534"sprite.position" = [300.0, 120.0]
535"#;
536
537 struct Fixture {
538 dir: PathBuf,
539 }
540
541 impl Fixture {
542 fn with_instances(tag: &str) -> Self {
544 let n = FIXTURE_COUNTER.fetch_add(1, Ordering::SeqCst);
545 let dir = std::env::temp_dir()
546 .join(format!("pite-ops-inst-{tag}-{}-{n}", std::process::id()));
547 let _ = std::fs::remove_dir_all(&dir);
548 std::fs::create_dir_all(dir.join("scenes")).expect("create scenes dir");
549 std::fs::write(dir.join("scenes").join("enemy.ptscn"), ENEMY_SCENE)
550 .expect("write enemy scene");
551 std::fs::write(dir.join("scenes").join("host.ptscn"), HOST_SCENE)
552 .expect("write host scene");
553 Self { dir }
554 }
555
556 fn host_path(&self) -> PathBuf {
557 self.dir.join("scenes").join("host.ptscn")
558 }
559
560 fn scene_dir(&self) -> PathBuf {
561 self.dir.join("scenes")
562 }
563
564 fn live_tree(&self) -> (SceneDoc, NodeTree) {
566 let doc = pite_scene::load_scene(&self.host_path()).expect("host parses");
567 let tree = pite_scene::build_tree(&doc, &self.scene_dir(), Some(&self.dir))
568 .expect("tree builds");
569 (doc, tree)
570 }
571
572 fn save(&self, tree: &NodeTree, source: &SceneDoc) -> Result<SceneDoc> {
574 build_doc(
575 tree,
576 Some(source),
577 Some(InstanceRefs {
578 scene_dir: &self.scene_dir(),
579 project_dir: Some(&self.dir),
580 }),
581 )
582 }
583
584 fn save_text(&self, tree: &NodeTree, source: &SceneDoc) -> String {
585 pite_scene::save_scene(&self.save(tree, source).expect("save builds"))
586 .expect("serializes")
587 }
588 }
589
590 impl Drop for Fixture {
591 fn drop(&mut self) {
592 std::fs::remove_dir_all(&self.dir).ok();
593 }
594 }
595
596 #[test]
597 fn instance_entries_are_not_flattened_into_nodes() {
598 let f = Fixture::with_instances("keep");
599 let (source, tree) = f.live_tree();
600 assert!(tree.contains(&NodeId::from("e1_enemy".to_string())));
601 let doc = f.save(&tree, &source).unwrap();
602
603 assert_eq!(doc.instance.len(), 1, "instance entry must survive save");
604 let inst = &doc.instance[0];
605 assert_eq!(inst.scene, "res://scenes/enemy.ptscn");
606 assert_eq!(inst.prefix, "e1_");
607 assert_eq!(inst.parent.as_deref(), Some("root"));
608 let ids: Vec<&str> = doc.node.iter().map(|n| n.id.as_str()).collect();
609 assert_eq!(
610 ids,
611 vec!["root", "player"],
612 "instance nodes must not be inlined"
613 );
614 let text = pite_scene::save_scene(&doc).unwrap();
615 assert!(
616 text.contains("[[instance]]"),
617 "serialized doc lost its instance"
618 );
619 assert!(
620 !text.contains("e1_enemy"),
621 "instance subtree leaked into nodes"
622 );
623 }
624
625 #[test]
626 fn overrides_survive_and_edits_become_overrides() {
627 let f = Fixture::with_instances("override");
628 let (source, mut tree) = f.live_tree();
629 let doc = f.save(&tree, &source).unwrap();
630 assert_eq!(
631 doc.instance[0].overrides.get("sprite.position"),
632 Some(&toml::Value::Array(vec![
633 toml::Value::Float(300.0),
634 toml::Value::Float(120.0)
635 ])),
636 "original override must be preserved verbatim"
637 );
638
639 set_prop(
640 &mut tree,
641 &NodeId::from("e1_sprite".to_string()),
642 "position",
643 PropValue::Vec2(1.0, 2.0),
644 )
645 .unwrap();
646 set_prop(
647 &mut tree,
648 &NodeId::from("player".to_string()),
649 "position",
650 PropValue::Vec2(9.0, 9.0),
651 )
652 .unwrap();
653 let doc = f.save(&tree, &source).unwrap();
654 assert_eq!(
655 doc.instance[0].overrides.get("sprite.position"),
656 Some(&toml::Value::Array(vec![
657 toml::Value::Float(1.0),
658 toml::Value::Float(2.0)
659 ])),
660 "edited instance prop must be written back as an override"
661 );
662 let player = doc.node.iter().find(|n| n.id == "player").unwrap();
663 assert!(
664 player.props.contains_key("position"),
665 "authored prop edits belong to the node, not to overrides"
666 );
667 }
668
669 #[test]
670 fn instance_save_is_byte_identical_on_reload() {
671 let f = Fixture::with_instances("stable");
672 let (source, tree) = f.live_tree();
673 let first = f.save_text(&tree, &source);
674
675 let reparsed = pite_scene::parse_scene_str(&first).unwrap();
676 let tree2 = pite_scene::build_tree(&reparsed, &f.scene_dir(), Some(&f.dir)).unwrap();
677 let second = f.save_text(&tree2, &reparsed);
678 assert_eq!(first, second, "save -> load -> save must be byte-identical");
679 }
680
681 #[test]
682 fn plain_scene_save_is_byte_identical_and_unaffected() {
683 let mut tree = rooted_tree();
684 let root = tree.root().cloned().unwrap();
685 for name in ["alpha", "beta", "gamma"] {
686 set_prop(&mut tree, &root, name, PropValue::Str(format!("v{name}"))).unwrap();
687 }
688 add_node(&mut tree, Some(root.clone()), "Label", "Score").unwrap();
689 let doc = build_doc(&tree, None, None).unwrap();
690 assert!(doc.instance.is_empty());
691 let first = pite_scene::save_scene(&doc).unwrap();
692 let reparsed = pite_scene::parse_scene_str(&first).unwrap();
693 let again = pite_scene::save_scene(&reparsed).unwrap();
694 assert_eq!(
695 first, again,
696 "plain scenes must serialize deterministically"
697 );
698 }
699
700 #[test]
701 fn structural_edits_inside_an_instance_fail_loudly() {
702 let f = Fixture::with_instances("drift");
703 let (source, tree) = f.live_tree();
704 let enemy = NodeId::from("e1_enemy".to_string());
705
706 let mut added = tree;
707 add_node(&mut added, Some(enemy.clone()), "Timer", "Extra").unwrap();
708 let err = f.save(&added, &source).unwrap_err();
709 assert!(
710 format!("{err:#}").contains("instanced subtree"),
711 "unexpected: {err:#}"
712 );
713
714 let mut removed = f.live_tree().1;
715 remove_node(&mut removed, &NodeId::from("e1_sprite".to_string())).unwrap();
716 let err = f.save(&removed, &source).unwrap_err();
717 assert!(
718 format!("{err:#}").contains("missing"),
719 "unexpected: {err:#}"
720 );
721
722 let mut renamed = f.live_tree().1;
723 renamed.get_mut(&enemy).unwrap().name = "Boss".to_string();
724 let err = f.save(&renamed, &source).unwrap_err();
725 assert!(
726 format!("{err:#}").contains("renamed"),
727 "unexpected: {err:#}"
728 );
729 }
730
731 #[test]
732 fn dogfood_scene_save_keeps_its_instance() {
733 let project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
734 .join("..")
735 .join("examples")
736 .join("minimal-2d");
737 let scene = project.join("scenes").join("main.ptscn");
738 let source = pite_scene::load_scene(&scene).expect("dogfood scene parses");
739 assert_eq!(
740 source.instance.len(),
741 1,
742 "dogfood fixture must keep an instance for this guard to mean anything"
743 );
744 let tree = pite_scene::build_tree(&source, &project.join("scenes"), Some(&project))
745 .expect("dogfood tree builds");
746 let refs = InstanceRefs {
747 scene_dir: &project.join("scenes"),
748 project_dir: Some(&project),
749 };
750 let doc = build_doc(&tree, Some(&source), Some(refs)).expect("save builds");
751 assert_eq!(doc.instance.len(), 1);
752 assert_eq!(doc.instance[0].prefix, "e1_");
753 assert!(
754 doc.instance[0].overrides.contains_key("sprite.position"),
755 "dogfood override must survive"
756 );
757 assert!(
758 !doc.node.iter().any(|n| n.id.starts_with("e1_")),
759 "instance subtree must not be inlined into nodes"
760 );
761 let first = pite_scene::save_scene(&doc).unwrap();
762 let reparsed = pite_scene::parse_scene_str(&first).unwrap();
763 let tree2 =
764 pite_scene::build_tree(&reparsed, &project.join("scenes"), Some(&project)).unwrap();
765 let second = pite_scene::save_scene(
766 &build_doc(&tree2, Some(&reparsed), Some(refs)).expect("second save builds"),
767 )
768 .unwrap();
769 assert_eq!(
770 first, second,
771 "dogfood save -> load -> save must be identical"
772 );
773 }
774
775 fn rooted_tree() -> NodeTree {
776 let mut tree = NodeTree::new();
777 add_node(&mut tree, None, "Node2D", "Main").unwrap();
778 tree
779 }
780
781 #[test]
782 fn add_remove_round_trip() {
783 let mut tree = rooted_tree();
784 let root = tree.root().cloned().unwrap();
785 let child = add_node(&mut tree, Some(root.clone()), "Sprite2D", "Player").unwrap();
786 assert_eq!(tree.len(), 2);
787 assert_eq!(tree.children_of(&root), vec![child.clone()]);
788 remove_node(&mut tree, &child).unwrap();
789 assert_eq!(tree.len(), 1);
790 assert!(tree.children_of(&root).is_empty());
791 }
792
793 #[test]
794 fn remove_root_is_refused() {
795 let mut tree = rooted_tree();
796 let root = tree.root().cloned().unwrap();
797 let err = remove_node(&mut tree, &root).unwrap_err();
798 assert!(err.to_string().contains("root"), "unexpected: {err:#}");
799 assert_eq!(tree.len(), 1);
800 }
801
802 #[test]
803 fn reparent_cycle_is_refused() {
804 let mut tree = rooted_tree();
805 let root = tree.root().cloned().unwrap();
806 let a = add_node(&mut tree, Some(root), "Node2D", "a").unwrap();
807 let b = add_node(&mut tree, Some(a.clone()), "Node2D", "b").unwrap();
808 let err = move_node(&mut tree, &a, Some(b.clone())).unwrap_err();
809 assert!(err.to_string().contains("cycle"), "unexpected: {err:#}");
810 assert!(tree.get(&a).unwrap().children.contains(&b));
811 }
812
813 #[test]
814 fn prop_set_get_round_trip() {
815 let mut tree = rooted_tree();
816 let root = tree.root().cloned().unwrap();
817 set_prop(&mut tree, &root, "speed", PropValue::Num(3.5)).unwrap();
818 set_prop(&mut tree, &root, "title", PropValue::Str("hi".to_string())).unwrap();
819 let node = tree.get(&root).unwrap();
820 assert_eq!(node.props.get("speed"), Some(&PropValue::Num(3.5)));
821 assert_eq!(
822 node.props.get("title"),
823 Some(&PropValue::Str("hi".to_string()))
824 );
825 }
826
827 #[test]
828 fn build_save_parse_round_trip_preserves_nodes_and_props() {
829 let mut tree = rooted_tree();
830 let root = tree.root().cloned().unwrap();
831 set_prop(&mut tree, &root, "position", PropValue::Vec2(100.0, 200.0)).unwrap();
832 let child = add_node(&mut tree, Some(root), "Label", "Score").unwrap();
833 set_prop(&mut tree, &child, "text", PropValue::Str("0".to_string())).unwrap();
834 let doc = build_doc(&tree, None, None).unwrap();
835 let text = pite_scene::save_scene(&doc).unwrap();
836 let again = pite_scene::parse_scene_str(&text).unwrap();
837 assert_eq!(again.node.len(), 2);
838 assert_eq!(again.root, doc.root);
839 let player = again
840 .node
841 .iter()
842 .find(|n| n.id == child.to_string())
843 .unwrap();
844 assert_eq!(
845 player.props.get("text"),
846 Some(&toml::Value::String("0".to_string()))
847 );
848 let main = again.node.iter().find(|n| n.id == doc.root).unwrap();
849 let pos = main.props.get("position").unwrap();
850 match pos {
851 toml::Value::Array(items) => assert_eq!(items.len(), 2),
852 other => panic!("expected position array, got {other:?}"),
853 }
854 }
855
856 #[test]
857 fn gutter_marks_extract_lines_and_ignore_clean_strings() {
858 let traceback = "Traceback (most recent call last):\n File \"res://scripts/player.py\", line 12, in _ready\n x = 1 / 0\n File \"res://scripts/player.py\", line 27, in _process\n update()\nZeroDivisionError: division by zero".to_string();
859 let clean = "all good, no errors".to_string();
860 let marks = gutter_marks(&[traceback, clean]);
861 assert_eq!(marks.len(), 1);
862 assert_eq!(marks[0].0, 27);
863 assert!(marks[0].1.contains("ZeroDivisionError"));
864 assert!(gutter_marks(&["clean".to_string()]).is_empty());
865 }
866
867 fn children_of(tree: &NodeTree, id: &str) -> Vec<String> {
868 tree.children_of(&NodeId::from(id.to_string()))
869 .iter()
870 .map(ToString::to_string)
871 .collect()
872 }
873
874 fn three_kids() -> NodeTree {
875 let mut tree = rooted_tree();
876 let root = tree.root().cloned().unwrap();
877 for name in ["a", "b", "c"] {
878 add_node(&mut tree, Some(root.clone()), "Node2D", name).unwrap();
879 }
880 tree
881 }
882
883 #[test]
884 fn place_node_reorders_and_moves_across_parents() {
885 let mut tree = three_kids();
886 let root = tree.root().cloned().unwrap();
887 place_node(&mut tree, &NodeId::from("c".to_string()), &root, 0).unwrap();
888 assert_eq!(children_of(&tree, "main"), vec!["c", "a", "b"]);
889 move_sibling(&mut tree, &NodeId::from("a".to_string()), 1).unwrap();
890 assert_eq!(children_of(&tree, "main"), vec!["c", "b", "a"]);
891 move_sibling(&mut tree, &NodeId::from("c".to_string()), -5).unwrap();
892 assert_eq!(children_of(&tree, "main"), vec!["c", "b", "a"]);
893 let sub = add_node(&mut tree, Some(root.clone()), "Node2D", "sub").unwrap();
894 place_node(&mut tree, &NodeId::from("a".to_string()), &sub, 0).unwrap();
895 assert_eq!(children_of(&tree, "main"), vec!["c", "b", "sub"]);
896 assert_eq!(children_of(&tree, &sub.to_string()), vec!["a"]);
897 }
898
899 #[test]
900 fn place_node_refuses_root_cycles_and_unknowns() {
901 let mut tree = three_kids();
902 let root = tree.root().cloned().unwrap();
903 assert!(place_node(&mut tree, &root, &NodeId::from("a".to_string()), 0).is_err());
904 assert!(place_node(
905 &mut tree,
906 &NodeId::from("a".to_string()),
907 &NodeId::from("a".to_string()),
908 0
909 )
910 .is_err());
911 place_node(
912 &mut tree,
913 &NodeId::from("b".to_string()),
914 &NodeId::from("a".to_string()),
915 0,
916 )
917 .unwrap();
918 assert!(place_node(
919 &mut tree,
920 &NodeId::from("a".to_string()),
921 &NodeId::from("b".to_string()),
922 0
923 )
924 .is_err());
925 assert!(place_node(&mut tree, &NodeId::from("nope".to_string()), &root, 0).is_err());
926 }
927
928 #[test]
929 fn unknown_type_is_an_error() {
930 let mut tree = rooted_tree();
931 let root = tree.root().cloned().unwrap();
932 let err = add_node(&mut tree, Some(root), "Nope", "x").unwrap_err();
933 assert!(err.to_string().contains("Nope"), "unexpected: {err:#}");
934 }
935}