OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_object_parent.gd

248 lines · gd ref:
extends SceneTree
## GATE — object REPARENTING (demo sprint §3; design approved 2026-10-04).
##   P1  parent_objects journals object.parent; the child's node is under the parent's,
##       and it did not move in the world (its xform is parent-local)
##   P2  moving the parent carries the child
##   P3  a child's object.transform is parent-local
##   P4  one undo of the parenting puts the child back at the top level, where it was
##   P5  a cycle is refused (fold and writer)
##   P6  a bot cannot be a child; it can be a parent
##   P7  X on a parent deletes the PARENT: its child moves up to the parent's parent and
##       stays where it is in the world (#120 F1, Blender's relink); one undo brings both
##       back, still parented. Delete Hierarchy deletes both; one undo, both back
##   P8  Ctrl+P parents the selection to the active object; Alt+P clears, keeping the
##       world position
##   P9  G with a parent AND its child selected moves the child once
##   P10 R with both selected turns the child once
##   P11 Outliner rows nest (child row under the parent row), parents first even when
##       the parent was created after the child
##   P12 an Outliner drop parents; a drop on the SceneObjects row unparents
##   P13 undoing the PARENT'S create keeps the child (at the top level), not freed
##   P14 a refold does not re-parent an unchanged child (same node instance)
##
## Run: CI=true HYPERTPS_DAEMON_TCP=127.0.0.1:1 "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_object_parent.gd

var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _persist: Node
var _stage2 := false
var _objs: Node
var _orphan := ""
var _gone := ""
var _c := ""


func _p(c: bool, m: String, got: Variant = "") -> void:
	checks += 1
	print(("ok   " if c else "FAIL: ") + m + ("" if str(got) == "" else "  (" + str(got) + ")"))
	if not c:
		ok = false


func _process(_d: float) -> bool:
	_frame += 1
	if _frame == 1:
		var dock := root.get_node("/root/BuildDock")
		dock.set_pref("snap", true)
		dock.set_pref("grid", 5)
		_map = (load("res://scenes/maps/map_flat.tscn") as PackedScene).instantiate()
		root.add_child(_map)
		return false
	if _frame < 30:
		return false
	if _frame == 30:
		_run()
		return false
	if _frame < 33 and _stage2:
		return false
	if _stage2:
		_run2()
	print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
	quit(0 if ok else 1)
	return true


func _key(code: int, ctrl := false, alt := false) -> InputEventKey:
	var e := InputEventKey.new()
	e.physical_keycode = code
	e.keycode = code
	e.ctrl_pressed = ctrl
	e.alt_pressed = alt
	e.pressed = true
	return e


func _node(dot: String) -> Node3D:
	return _map.object_node(dot) as Node3D


func _world(dot: String) -> Vector3:
	var n := _node(dot)
	return n.global_position if n != null else Vector3.INF


func _undo() -> void:
	_persist.undo_gesture(_persist.newest_live_gesture())
	_persist.refold()


func _at(p: Vector3) -> Transform3D:
	return Transform3D(Basis(), p)


func _run() -> void:
	_persist = _map.get("_persist")
	var keet: Node = root.get_node_or_null("/root/KeetService")
	if keet != null:
		keet.current_member_id = "gate-author"
	var gs: Node = root.get_node_or_null("/root/GameState")
	if gs != null:
		gs.route("world:level:*", 1, "peer")
	root.get_node("/root/GameMode").set_mode("build")
	_p(_map.has_method("parent_objects"), "the map has parent_objects")
	if not _map.has_method("parent_objects"):
		return
	var objs: Node = _map.get_node("SceneObjects")

	# ---- P1 ----
	var a: String = _map.create_object("block.bevelled", Transform3D(Basis(Vector3.UP, PI / 2), Vector3(2, 0, 0)))
	var c: String = _map.create_object("marker", _at(Vector3(5, 1, 3)))
	_p(_map.parent_objects([c], a) == 1, "P1 parent_objects writes one record")
	_p(_node(c).get_parent() == _node(a), "P1 the child's node is under the parent's")
	_p(_world(c).is_equal_approx(Vector3(5, 1, 3)), "P1 the child did not move in the world", _world(c))
	_p(String(_map.object_parent(c)) == a, "P1 object_parent names the parent")
	# ---- P2 ----
	_map.move_objects({a: Transform3D(Basis(Vector3.UP, PI / 2), Vector3(2, 0, 10))}, "Move")
	_p(_world(c).is_equal_approx(Vector3(5, 1, 13)), "P2 moving the parent carries the child", _world(c))
	# ---- P3 ----
	_map.move_object(c, _at(Vector3(0, 2, 0)))
	_p(_world(c).is_equal_approx(Vector3(2, 2, 10)), "P3 a child's transform is parent-local", _world(c))
	_undo()
	_undo()
	_p(_world(c).is_equal_approx(Vector3(5, 1, 3)), "P3 (two undos: back to the parenting)", _world(c))
	# ---- P4 ----
	_undo()
	_p(_node(c).get_parent() == objs and _world(c).is_equal_approx(Vector3(5, 1, 3)),
		"P4 undoing the parenting: top level, same place", _world(c))
	# ---- P5 ----
	_map.parent_objects([c], a)
	_p(_map.parent_objects([a], c) == 0, "P5 the writer refuses a cycle")
	_persist.record_at(_persist.reserve_dot(), "object.parent", {"target": a, "parent": c,
		"xform": SceneObjects.xform_to(Transform3D.IDENTITY)})
	_persist.refold()
	_p(_node(a).get_parent() == objs and _node(c).get_parent() == _node(a), "P5 the fold refuses a forged cycle")
	_undo()       # the forged record
	# ---- P6 ----
	var bot: String = _map.place_bot("idle", "marker")
	_p(_map.parent_objects([bot], a) == 0, "P6 a bot cannot be a child")
	var m2: String = _map.create_object("marker", _at(Vector3(-4, 0, 0)))
	_p(_map.parent_objects([m2], bot) == 1 and _node(m2).get_parent() == _node(bot), "P6 a bot can be a parent")
	# ---- P7 ----
	var cw := _world(c)
	_map.delete_objects([a])
	_p(_node(a) == null and _node(c) != null, "P7 X on the parent deletes only the parent")
	_p(_node(c) != null and _node(c).get_parent() == objs and _world(c).is_equal_approx(cw),
		"P7 ... its child moved up, same place in the world", _world(c) if _node(c) != null else null)
	_undo()
	_p(_node(a) != null and _node(c) != null and _node(c).get_parent() == _node(a), "P7 one undo: both back, still parented")
	_map.select_objects([a], a)
	var rows_x: Array = (_map.delete_sections()[0] as Array)[1]
	_p(rows_x.any(func(r: Array) -> bool: return String(r[0]) == "builder.delete_hierarchy"),
		"P7 the X menu offers Delete Hierarchy (the parent has a child)")
	var dh := InputEventAction.new()
	dh.action = "builder.delete_hierarchy"
	dh.pressed = true
	_map._unhandled_input(dh)                    # Keymap.run's path for a menu-only action
	_p(_node(a) == null and _node(c) == null, "P7 Delete Hierarchy deletes the child too")
	_undo()
	_p(_node(a) != null and _node(c) != null and _node(c).get_parent() == _node(a), "P7 one undo: both back, still parented")
	# ---- P8 ----
	var k1: String = _map.create_object("marker", _at(Vector3(20, 0, 0)))
	var k2: String = _map.create_object("marker", _at(Vector3(22, 0, 0)))
	var pa: String = _map.create_object("block.bevelled", _at(Vector3(25, 0, 0)))
	_map.select_objects([k1, k2, pa], pa)
	_map._unhandled_key_input(_key(KEY_P, true))
	_p(_node(k1).get_parent() == _node(pa) and _node(k2).get_parent() == _node(pa), "P8 Ctrl+P parents the selection to the active")
	_map.move_objects({pa: _at(Vector3(25, 3, 0))}, "Move")
	_map.select_objects([k1], k1)
	_map._unhandled_key_input(_key(KEY_P, false, true))
	_p(_node(k1).get_parent() == objs and _world(k1).is_equal_approx(Vector3(20, 3, 0)),
		"P8 Alt+P clears the parent and keeps the world position", _world(k1))
	# ---- P9 ----
	var stack: Node = _map.call("_ensure_ops")
	var c0 := _world(k2)
	var p0 := _world(pa)
	_map.select_objects([k2, pa], pa)
	_map._unhandled_key_input(_key(KEY_G))
	_p(stack.active is MoveOperator, "P9 G starts move")
	_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	stack.confirm()
	var dp := _world(pa) - p0
	var dc := _world(k2) - c0
	_p(dp.length() > 0.01 and dc.is_equal_approx(dp), "P9 the child moved ONCE with its parent", "%s vs %s" % [dc, dp])
	# ---- P10 ----
	c0 = _world(k2)
	_map.select_objects([k2, pa], pa)
	_map._unhandled_key_input(_key(KEY_R))
	for i in 6:
		_map._unhandled_key_input(_key(KEY_BRACKETRIGHT))       # 90° about Y
	stack.confirm()
	var pv := _world(pa)                 # one object root -> turns about its own origin
	var want := pv + Basis(Vector3.UP, PI / 2) * (c0 - pv)
	_p(_world(k2).is_equal_approx(want), "P10 the child turned ONCE with its parent", "%s vs %s" % [_world(k2), want])
	# ---- P11 ----
	var late_child: String = _map.create_object("marker", _at(Vector3(30, 0, 0)))
	var late_parent: String = _map.create_object("block.bevelled", _at(Vector3(32, 0, 0)))
	_map.parent_objects([late_child], late_parent)
	var om := OutlinerModel.new(_map)
	var order: Array = []
	var parent_of_row := {}
	for r: Dictionary in om.rows():
		order.append(String(r["id"]))
		parent_of_row[String(r["id"])] = String(r["parent"])
	_p(String(parent_of_row.get("obj:" + late_child, "")) == "obj:" + late_parent, "P11 the child row nests under the parent row",
		parent_of_row.get("obj:" + late_child))
	_p(order.find("obj:" + late_parent) >= 0 and order.find("obj:" + late_parent) < order.find("obj:" + late_child),
		"P11 parent row before child row, though created after it")
	# ---- P12 ----
	var panel: Node = load("res://scenes/game/outliner_panel.gd").new()
	root.add_child(panel)
	panel.setup(_map, "gate_object_parent")
	var dropper: String = _map.create_object("marker", _at(Vector3(40, 0, 0)))
	panel.refresh()
	_p(panel.has_method("drop_objects"), "P12 the Outliner has drop_objects")
	if panel.has_method("drop_objects"):
		panel.drop_objects([dropper], "obj:" + late_parent)
		_p(_node(dropper).get_parent() == _node(late_parent), "P12 a drop on an object row parents")
		panel.drop_objects([dropper], "node:SceneObjects")
		_p(_node(dropper).get_parent() == objs and _world(dropper).is_equal_approx(Vector3(40, 0, 0)),
			"P12 a drop on SceneObjects unparents, in place", _world(dropper))
	panel.queue_free()
	# ---- P13 ----
	_orphan = _map.create_object("marker", _at(Vector3(50, 0, 0)))
	_gone = _map.create_object("block.bevelled", _at(Vector3(52, 0, 0)))
	_map.parent_objects([_orphan], _gone)
	for r: Dictionary in _persist.records():
		if "%s:%d" % [String(r.get("author", "")), int(r.get("seq", -1))] == _gone:
			_persist.undo_gesture(String(r.get("g", _gone)))
	_persist.refold()
	_objs = objs
	_c = c
	_stage2 = true          # checked after the frame ends (queue_free is deferred)


func _run2() -> void:
	var objs := _objs
	_p(_node(_gone) == null and _node(_orphan) != null and is_instance_valid(_node(_orphan)),
		"P13 undoing the parent's create keeps the child")
	_p(_node(_orphan) != null and _node(_orphan).get_parent() == objs, "P13 the child is at the top level")
	# ---- P14 ----
	var before := _node(_c).get_instance_id()
	var parent_before := _node(_c).get_parent()
	_persist.refold()
	_p(_node(_c).get_instance_id() == before and _node(_c).get_parent() == parent_before,
		"P14 a refold keeps the child node and its parent")
	root.get_node("/root/GameMode").set_mode("play")